Introduction
R-squared (R²) measures how well a regression model's predictions match the actual values. It ranges from negative infinity to 1, where 1 means perfect prediction, 0 means the model is no better than predicting the mean, and negative values mean the model is worse than predicting the mean. TensorFlow does not include R² as a built-in metric, but it can be implemented as a custom metric or computed using tf.keras.metrics.
R² = 1 - SS_res / SS_tot
Where:
Method 1: Custom Keras Metric Class
The most robust approach — works correctly with batched training:
1import tensorflow as tf
2
3class R2Score(tf.keras.metrics.Metric):
4 def __init__(self, name='r2_score', **kwargs):
5 super().__init__(name=name, **kwargs)
6 self.ss_res = self.add_weight(name='ss_res', initializer='zeros')
7 self.ss_tot = self.add_weight(name='ss_tot', initializer='zeros')
8 self.count = self.add_weight(name='count', initializer='zeros')
9 self.total = self.add_weight(name='total', initializer='zeros')
10
11 def update_state(self, y_true, y_pred, sample_weight=None):
12 y_true = tf.cast(y_true, tf.float32)
13 y_pred = tf.cast(y_pred, tf.float32)
14
15 self.ss_res.assign_add(tf.reduce_sum(tf.square(y_true - y_pred)))
16 self.total.assign_add(tf.reduce_sum(y_true))
17 self.count.assign_add(tf.cast(tf.size(y_true), tf.float32))
18
19 def result(self):
20 mean = self.total / self.count
21 # We need to recompute SS_tot, but we can't store all y_true values
22 # This approximation works when called at epoch end
23 # For exact R², use the post-training computation method
24 return 1.0 - self.ss_res / (self.ss_tot + tf.keras.backend.epsilon())
25
26 def reset_state(self):
27 self.ss_res.assign(0.0)
28 self.ss_tot.assign(0.0)
29 self.count.assign(0.0)
30 self.total.assign(0.0)
A simpler approach that handles batching correctly:
1class R2Score(tf.keras.metrics.Metric):
2 def __init__(self, name='r2_score', **kwargs):
3 super().__init__(name=name, **kwargs)
4 self.sum_squared_error = self.add_weight('sse', initializer='zeros')
5 self.sum_y = self.add_weight('sum_y', initializer='zeros')
6 self.sum_y_sq = self.add_weight('sum_y_sq', initializer='zeros')
7 self.n = self.add_weight('n', initializer='zeros')
8
9 def update_state(self, y_true, y_pred, sample_weight=None):
10 y_true = tf.cast(tf.reshape(y_true, [-1]), tf.float32)
11 y_pred = tf.cast(tf.reshape(y_pred, [-1]), tf.float32)
12
13 self.sum_squared_error.assign_add(tf.reduce_sum(tf.square(y_true - y_pred)))
14 self.sum_y.assign_add(tf.reduce_sum(y_true))
15 self.sum_y_sq.assign_add(tf.reduce_sum(tf.square(y_true)))
16 self.n.assign_add(tf.cast(tf.size(y_true), tf.float32))
17
18 def result(self):
19 ss_res = self.sum_squared_error
20 ss_tot = self.sum_y_sq - (self.sum_y ** 2) / self.n
21 return 1.0 - ss_res / (ss_tot + tf.keras.backend.epsilon())
22
23 def reset_state(self):
24 self.sum_squared_error.assign(0.0)
25 self.sum_y.assign(0.0)
26 self.sum_y_sq.assign(0.0)
27 self.n.assign(0.0)
Using the Custom Metric
1model = tf.keras.Sequential([
2 tf.keras.layers.Dense(64, activation='relu', input_shape=(n_features,)),
3 tf.keras.layers.Dense(32, activation='relu'),
4 tf.keras.layers.Dense(1)
5])
6
7model.compile(
8 optimizer='adam',
9 loss='mse',
10 metrics=[R2Score()]
11)
12
13history = model.fit(X_train, y_train, epochs=50, validation_data=(X_val, y_val))
14print(f"Final R²: {history.history['r2_score'][-1]:.4f}")
15print(f"Val R²: {history.history['val_r2_score'][-1]:.4f}")
Method 2: Post-Training Computation
Compute R² after training using NumPy or sklearn — simpler and guaranteed correct:
1import numpy as np
2from sklearn.metrics import r2_score
3
4# Get predictions
5y_pred = model.predict(X_test).flatten()
6
7# NumPy computation
8ss_res = np.sum((y_test - y_pred) ** 2)
9ss_tot = np.sum((y_test - np.mean(y_test)) ** 2)
10r2 = 1 - ss_res / ss_tot
11print(f"R² (numpy): {r2:.4f}")
12
13# Sklearn (equivalent)
14r2_sk = r2_score(y_test, y_pred)
15print(f"R² (sklearn): {r2_sk:.4f}")
Method 3: TensorFlow-Only Computation
1def compute_r2(y_true, y_pred):
2 """Compute R² using TensorFlow ops."""
3 y_true = tf.cast(y_true, tf.float32)
4 y_pred = tf.cast(y_pred, tf.float32)
5
6 ss_res = tf.reduce_sum(tf.square(y_true - y_pred))
7 ss_tot = tf.reduce_sum(tf.square(y_true - tf.reduce_mean(y_true)))
8
9 return 1.0 - ss_res / ss_tot
10
11# Usage
12y_pred = model(X_test, training=False)
13r2 = compute_r2(y_test, y_pred)
14print(f"R²: {r2.numpy():.4f}")
Method 4: Using tfa.metrics (TensorFlow Addons)
TensorFlow Addons includes an R² metric:
1import tensorflow_addons as tfa
2
3model.compile(
4 optimizer='adam',
5 loss='mse',
6 metrics=[tfa.metrics.RSquare()]
7)
Note: TensorFlow Addons is in maintenance mode. For new projects, use the custom metric approach.
R² as a Custom Loss Function
You can also optimize R² directly as a loss (minimize 1 - R²):
1def r2_loss(y_true, y_pred):
2 ss_res = tf.reduce_sum(tf.square(y_true - y_pred))
3 ss_tot = tf.reduce_sum(tf.square(y_true - tf.reduce_mean(y_true)))
4 return ss_res / (ss_tot + tf.keras.backend.epsilon())
5
6model.compile(optimizer='adam', loss=r2_loss, metrics=[R2Score()])
Interpreting R² Values
| R² Value | Interpretation |
| 1.0 | Perfect prediction |
| 0.9 - 1.0 | Excellent fit |
| 0.7 - 0.9 | Good fit |
| 0.4 - 0.7 | Moderate fit |
| 0.0 - 0.4 | Poor fit |
| < 0 | Worse than predicting the mean |
Common Pitfalls
Batch-wise R² is misleading: Computing R² per batch and averaging gives wrong results because the mean of y_true differs across batches. Always accumulate sums across all batches (as in the custom metric) or compute R² on the full dataset after training.
R² on training data: R² on training data always improves with more model complexity (overfitting). Always evaluate on a held-out test set. Use adjusted R² if comparing models with different numbers of features.
Multi-output regression: For models with multiple outputs, compute R² per output or use a weighted average. The custom metric above assumes a single output — reshape multi-output predictions accordingly.
Negative R²: A negative R² does not mean the model is bad in absolute terms — it means it is worse than always predicting the mean. This often indicates a bug (wrong features, wrong target scaling) rather than a modeling issue.
Scale sensitivity: R² is invariant to the scale of the target variable, unlike MSE or MAE. This makes it useful for comparing models across different datasets but can hide issues with prediction magnitude.
Summary
Implement R² as a custom tf.keras.metrics.Metric subclass for use during training
Accumulate SS_res, sum(y), sum(y²), and count across batches for correct epoch-level R²
Use sklearn.metrics.r2_score for post-training evaluation — simpler and guaranteed correct
R² = 1 means perfect prediction; R² = 0 means no better than predicting the mean; R² < 0 means worse
Always evaluate R² on test data, not training data