机器学习中的ESS/RSS/TSS:如何用Python代码计算并可视化这些关键指标

在构建和评估一个机器学习模型,尤其是回归模型时,我们常常会听到R²(决定系数)这个指标。它告诉我们模型解释了目标变量多少比例的变异。但你是否想过,这个看似简单的数字背后,是由哪些更基础的“积木”搭建而成的?这些“积木”就是ESS(解释平方和)、RSS(残差平方和)和TSS(总平方和)。理解它们,不仅仅是记住一个公式 TSS = ESS + RSS,更是深入模型评估内核,诊断模型表现,甚至指导模型优化的关键一步。

对于数据分析师和机器学习实践者来说,仅仅知道R²的数值是远远不够的。当R²偏低时,是模型本身解释力不足(ESS太小),还是数据中存在大量无法捕捉的噪声(RSS太大)?通过亲手计算并可视化ESS、RSS和TSS,我们可以将模型的“黑箱”打开一条缝,直观地看到预测值、真实值与平均值之间的关系,从而做出更明智的决策。本文将彻底抛开枯燥的理论推导,直接带你用Python的NumPy、Pandas和Matplotlib库,从零开始计算这些指标,并通过丰富的图表,让你“看见”模型评估的数学之美。无论你是刚入门的新手,还是希望夯实基础的从业者,这篇文章都将提供一套可直接上手的实战指南。

1. 核心概念拆解:不止于公式的记忆

在动手写代码之前,我们需要对ESS、RSS和TSS这三个概念建立清晰、直观的理解。很多教程止步于公式定义,但我们今天要深入到它们的几何意义和实际解读中。

总平方和(TSS) 衡量的是因变量 y 自身的波动程度。你可以把它想象成数据的“原始能量”或“总不确定性”。它的计算方式是所有真实值 y_i 与其平均值 y_mean 的偏差的平方和。如果所有 y_i 都等于平均值,那么TSS为0,说明数据没有任何变化,预测也就失去了意义。TSS是一个基准,它不依赖于任何模型,只依赖于数据本身。

解释平方和(ESS),有时也叫回归平方和,衡量的是我们的模型所“捕捉”到的那部分波动。它是模型预测值 y_hat_i 与平均值 y_mean 的偏差的平方和。ESS越大,说明模型预测值的分布越“远离”平均值,即模型越有能力解释数据相对于平均水平的偏离。一个完美的模型,其预测值的波动应该能完全反映真实值的波动。

残差平方和(RSS) 则是模型“遗漏”或“无法解释”的那部分波动。它是真实值 y_i 与模型预测值 y_hat_i 之间的差异(即残差)的平方和。RSS是模型拟合好坏最直接的度量之一,也是许多模型(如线性回归)训练时试图最小化的目标函数。RSS越小,说明预测线与真实数据点靠得越近。

它们三者的关系 TSS = ESS + RSS 是一个美妙的数学分解。它将数据的总波动(TSS)拆解为模型能解释的部分(ESS)和模型不能解释的部分(RSS)。基于此,决定系数 R² 的定义就非常自然了:R² = ESS / TSS = 1 - (RSS / TSS)。它直接代表了模型解释的波动占总波动的比例。

为了更直观地对比,我们可以用一个表格来总结:

指标 全称 计算公式 物理意义 与模型的关系
TSS Total Sum of Squares Σ(y_i - y_mean)² 数据自身的总波动/不确定性 与模型无关,是评估基准
ESS Explained Sum of Squares Σ(y_hat_i - y_mean)² 模型预测值带来的波动,即模型解释掉的部分 模型越好,ESS通常越大(在TSS中占比高)
RSS Residual Sum of Squares Σ(y_i - y_hat_i)² 预测值与真实值的差距平方和,即模型未解释的部分 模型训练的目标是最小化RSS

注意:在有些文献或库中,ESS也可能被称为SSR(Sum of Squares due to Regression),而RSS被称为SSE(Sum of Squares of Errors)。本文统一使用ESS/RSS的称谓,避免混淆。

理解了这些,我们就可以进入实战环节了。我们将创建一个简单的线性回归场景,并一步步计算出这些值。

2. 实战准备:构建数据与基线模型

我们首先导入必要的Python库,并人工构造一份带有一定线性关系且包含些许噪声的数据。这样我们可以完全控制数据生成过程,便于观察。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
import seaborn as sns

# 设置随机种子以保证结果可复现
np.random.seed(42)

# 生成模拟数据
n_samples = 50
X = np.linspace(0, 10, n_samples)  # 特征,从0到10的50个点
true_slope = 2.5
true_intercept = 1.0
noise = np.random.randn(n_samples) * 3  # 加入标准差为3的随机噪声

y_true = true_slope * X + true_intercept + noise  # 真实的y值(带噪声)

# 将数据转换为DataFrame,方便查看
df = pd.DataFrame({'X': X, 'y_true': y_true})
print(df.head())

接下来,我们使用Scikit-learn快速拟合一个简单的线性回归模型,并得到预测值。

# 重塑X的形状以满足sklearn要求 (n_samples, n_features)
X_reshaped = X.reshape(-1, 1)

# 创建并训练线性回归模型
model = LinearRegression()
model.fit(X_reshaped, y_true)

# 获取模型参数和预测值
slope = model.coef_[0]
intercept = model.intercept_
y_pred = model.predict(X_reshaped)

print(f"拟合的直线方程: y = {slope:.3f} * X + {intercept:.3f}")
print(f"真实的直线方程: y = {true_slope} * X + {true_intercept}")

# 将预测值加入DataFrame
df['y_pred'] = y_pred

现在,我们已经有了:

  • y_true: 真实的观测值
  • y_pred: 模型的预测值
  • y_mean: 所有真实观测值的平均值(接下来计算)

让我们先直观地看一下数据、拟合的回归线以及平均值线。

# 计算y的真实平均值
y_mean = np.mean(y_true)
df['y_mean'] = y_mean  # 这是一个常数列

# 可视化
plt.figure(figsize=(10, 6))
plt.scatter(X, y_true, alpha=0.7, label='观测数据 (y_true)', color='steelblue')
plt.plot(X, y_pred, color='crimson', linewidth=2.5, label=f'回归线: y = {slope:.2f}X + {intercept:.2f}')
plt.axhline(y=y_mean, color='darkgreen', linestyle='--', linewidth=2, label=f'平均值线 (y_mean = {y_mean:.2f})')

plt.xlabel('特征 X', fontsize=12)
plt.ylabel('目标变量 y', fontsize=12)
plt.title('数据散点、回归线与平均值线', fontsize=14)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

这张图是我们所有分析的基石。图中包含了:

  • 蓝色的散点:原始数据点 (X, y_true)
  • 红色的实线:我们的线性回归模型拟合出的直线。
  • 绿色的虚线:所有 y_true 的平均值 y_mean

接下来,我们将在这张图上,通过几何图形来形象化地理解TSS、ESS和RSS。

3. 手动计算与几何可视化:让公式“动起来”

计算这三个指标本身非常简单,就是对应差值的平方和。我们先用最基础的NumPy数组操作来计算一遍。

# 计算核心指标
TSS = np.sum((y_true - y_mean) ** 2)
ESS = np.sum((y_pred - y_mean) ** 2)
RSS = np.sum((y_true - y_pred) ** 2)

# 计算R-squared
r_squared_from_components = ESS / TSS
r_squared_sklearn = r2_score(y_true, y_pred)

print("=== 手动计算结果 ===")
print(f"总平方和 TSS: {TSS:.2f}")
print(f"解释平方和 ESS: {ESS:.2f}")
print(f"残差平方和 RSS: {RSS:.2f}")
print(f"验证 TSS == ESS + RSS: {np.isclose(TSS, ESS + RSS)}")
print(f"\n由分量计算的决定系数 R² = ESS / TSS: {r_squared_from_components:.4f}")
print(f"Scikit-learn 计算的 R²: {r_squared_sklearn:.4f}")
print(f"两者是否一致: {np.isclose(r_squared_from_components, r_squared_sklearn)}")

运行上述代码,你会看到TSS、ESS、RSS的具体数值,并验证 TSS = ESS + RSS 是否成立,同时对比手动计算的R²与Scikit-learn内置函数的结果是否一致。这能确保我们的计算逻辑是正确的。

现在,让我们通过可视化来理解每一个平方和到底代表了图中哪一段距离的平方。我们将选择一个特定的数据点(比如第 i=25 个点)来演示。

# 选择一个索引来重点展示
i = 25
x_i = X[i]
y_true_i = y_true[i]
y_pred_i = y_pred[i]

plt.figure(figsize=(12, 8))

# 1. 绘制所有数据点、回归线和均值线
plt.scatter(X, y_true, alpha=0.3, color='steelblue', label='所有观测点')
plt.plot(X, y_pred, color='crimson', linewidth=2, label='回归线')
plt.axhline(y=y_mean, color='darkgreen', linestyle='--', linewidth=1.5, label='平均值线')

# 2. 高亮选中的点
plt.scatter(x_i, y_true_i, color='red', s=100, zorder=5, label=f'选中点 (i={i})')
plt.scatter(x_i, y_pred_i, color='orange', s=100, marker='s', zorder=5, label='该点的预测值')

# 3. 绘制三条关键的线段,并用箭头和文本来标注
# TSS 线段: 从均值线到真实值
plt.plot([x_i, x_i], [y_mean, y_true_i], color='black', linewidth=3, linestyle='-', label='TSS分量 (y_true - y_mean)')
plt.annotate('', xy=(x_i, y_true_i), xytext=(x_i, y_mean),
             arrowprops=dict(arrowstyle='<->', color='black', lw=2))

# ESS 线段: 从均值线到预测值
plt.plot([x_i+0.1, x_i+0.1], [y_mean, y_pred_i], color='blue', linewidth=3, linestyle='-', label='ESS分量 (y_pred - y_mean)')
plt.annotate('', xy=(x_i+0.1, y_pred_i), xytext=(x_i+0.1, y_mean),
             arrowprops=dict(arrowstyle='<->', color='blue', lw=2))

# RSS 线段: 从预测值到真实值
plt.plot([x_i-0.1, x_i-0.1], [y_pred_i, y_true_i], color='purple', linewidth=3, linestyle='-', label='RSS分量 (y_true - y_pred)')
plt.annotate('', xy=(x_i-0.1, y_true_i), xytext=(x_i-0.1, y_pred_i),
             arrowprops=dict(arrowstyle='<->', color='purple', lw=2))

# 添加文本标注
plt.text(x_i+0.5, (y_true_i + y_mean)/2, f'TSS部分\n(总偏差)', color='black', fontsize=10, ha='left')
plt.text(x_i+0.6, (y_pred_i + y_mean)/2, f'ESS部分\n(已解释偏差)', color='blue', fontsize=10, ha='left')
plt.text(x_i-0.8, (y_true_i + y_pred_i)/2, f'RSS部分\n(残差)', color='purple', fontsize=10, ha='right')

plt.xlabel('特征 X')
plt.ylabel('目标变量 y')
plt.title('单个数据点上TSS、ESS、RSS的几何意义分解', fontsize=14)
plt.legend(loc='upper left')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

这张图非常关键,它清晰地展示了对于一个特定的数据点:

  • 黑色双箭头代表了 y_true_i - y_mean,这是该点总偏差的长度。TSS是所有点这个长度的平方的和。
  • 蓝色双箭头代表了 y_pred_i - y_mean,这是模型为该点所解释的偏差的长度。ESS是所有点这个长度的平方的和。
  • 紫色双箭头代表了 y_true_i - y_pred_i,即残差,是模型未能解释的偏差的长度。RSS是所有点这个长度的平方的和。

从几何上看,对于这个点,黑色线段(总偏差)被分解成了蓝色线段(解释部分)和紫色线段(残差部分)的向量和。而平方和的关系,正是源于这种直角三角形的几何关系(在最小二乘回归中,预测值向量与残差向量正交)。

为了更全面地展示所有点的情况,我们可以绘制残差图,并标注出每个点的残差(即RSS的平方根)。

# 计算残差
residuals = y_true - y_pred

plt.figure(figsize=(14, 5))

# 子图1:带残差连接线的拟合图
plt.subplot(1, 2, 1)
plt.scatter(X, y_true, alpha=0.6, color='steelblue', label='观测值')
plt.plot(X, y_pred, color='crimson', linewidth=2, label='预测线')
plt.vlines(X, ymin=y_pred, ymax=y_true, color='purple', alpha=0.4, linewidth=0.8, label='残差(垂直距离)')
plt.xlabel('特征 X')
plt.ylabel('目标变量 y')
plt.title('线性拟合与残差示意图')
plt.legend()
plt.grid(True, alpha=0.3)

# 子图2:残差 vs 预测值图(诊断图)
plt.subplot(1, 2, 2)
plt.scatter(y_pred, residuals, alpha=0.6, color='purple')
plt.axhline(y=0, color='black', linestyle='--', linewidth=1)
plt.xlabel('预测值 (y_pred)')
plt.ylabel('残差 (y_true - y_pred)')
plt.title('残差图:检查同方差性和随机性')
plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

左图清晰地用紫色垂直线段展示了每个数据点的残差大小,这些线段长度的平方和就是RSS。右图的残差图则是一种重要的模型诊断工具,理想情况下,残差应随机分布在0线上下,无明显模式。如果出现漏斗形或曲线形,则暗示模型可能存在问题,如异方差性或未捕捉的非线性关系。

4. 深入分析与高级应用:超越基础计算

掌握了基础计算和可视化后,我们可以利用这些指标进行更深入的分析。例如,比较不同模型的拟合效果,或者探究在多项式回归中,随着模型复杂度增加,这些指标如何变化。

场景一:比较两个不同回归模型的性能

假设我们除了线性模型,还尝试了一个更简单的模型(比如只用平均值预测)和一个更复杂的模型(比如高阶多项式)。我们可以通过对比它们的ESS和RSS来评估。

# 模型1:简单均值模型 (Benchmark)
y_pred_mean = np.full_like(y_true, y_mean) # 始终预测为平均值
TSS_mean = np.sum((y_true - y_mean) ** 2) # 对于均值模型,TSS不变
ESS_mean = np.sum((y_pred_mean - y_mean) ** 2) # 预测值等于均值,所以ESS为0
RSS_mean = np.sum((y_true - y_pred_mean) ** 2) # RSS就等于TSS
r2_mean = 1 - (RSS_mean / TSS_mean) # R²为0

# 模型2:我们拟合的线性模型 (已计算)
# 模型3:一个可能过拟合的复杂模型(这里用9阶多项式演示)
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import make_pipeline

poly_model = make_pipeline(PolynomialFeatures(degree=9), LinearRegression())
poly_model.fit(X_reshaped, y_true)
y_pred_poly = poly_model.predict(X_reshaped)

ESS_poly = np.sum((y_pred_poly - y_mean) ** 2)
RSS_poly = np.sum((y_true - y_pred_poly) ** 2)
r2_poly = r2_score(y_true, y_pred_poly)

# 汇总比较
comparison_df = pd.DataFrame({
    '模型': ['均值模型 (基准)', '线性回归', '9阶多项式回归'],
    'ESS': [ESS_mean, ESS, ESS_poly],
    'RSS': [RSS_mean, RSS, RSS_poly],
    'R²': [r2_mean, r_squared_sklearn, r2_poly]
})
print(comparison_df.round(2))

通过这个对比表格,你可以清晰地看到:

  • 均值模型的ESS为0,RSS等于TSS,R²为0。这是最差的基准。
  • 线性模型的ESS显著大于0,RSS小于TSS,R²为一个介于0和1之间的值。
  • 9阶多项式模型的RSS可能更小,R²可能更高(甚至接近1),但这并不意味着它一定是更好的模型,因为它可能对训练数据过拟合了。它的ESS会非常大,几乎接近TSS。

场景二:观察模型复杂度增加时,ESS和RSS的变化趋势

这是一个非常有益的练习,可以帮助我们理解偏差-方差权衡。

degrees = range(1, 11) # 测试1到10阶多项式
ess_list = []
rss_list = []
r2_list = []

for degree in degrees:
    model_poly = make_pipeline(PolynomialFeatures(degree=degree), LinearRegression())
    model_poly.fit(X_reshaped, y_true)
    y_pred_deg = model_poly.predict(X_reshaped)

    ess_list.append(np.sum((y_pred_deg - y_mean) ** 2))
    rss_list.append(np.sum((y_true - y_pred_deg) ** 2))
    r2_list.append(r2_score(y_true, y_pred_deg))

# 可视化变化趋势
plt.figure(figsize=(12, 4))

plt.subplot(1, 3, 1)
plt.plot(degrees, ess_list, marker='o', color='blue')
plt.xlabel('多项式阶数')
plt.ylabel('ESS')
plt.title('ESS 随模型复杂度变化')
plt.grid(True, alpha=0.3)

plt.subplot(1, 3, 2)
plt.plot(degrees, rss_list, marker='s', color='purple')
plt.xlabel('多项式阶数')
plt.ylabel('RSS')
plt.title('RSS 随模型复杂度变化')
plt.grid(True, alpha=0.3)

plt.subplot(1, 3, 3)
plt.plot(degrees, r2_list, marker='^', color='green')
plt.xlabel('多项式阶数')
plt.ylabel('R²')
plt.axhline(y=1, color='grey', linestyle='--', alpha=0.5)
plt.title('R² 随模型复杂度变化')
plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

观察这三张图,你会发现一个典型模式:

  • ESS 随着模型复杂度增加而单调递增。因为更复杂的模型有能力“解释”数据中更多的波动,甚至包括噪声。
  • RSS 随着模型复杂度增加而单调递减。因为模型更努力地去拟合每一个数据点,包括噪声点,使得残差减小。
  • 也随之单调递增,在训练集上最终会趋近于1(完美拟合)。

提示:这正是过拟合在训练集上的表现。在训练集上,ESS和R²会给我们模型“越来越好”的假象。因此,绝不能只依赖训练集上的R²来评估模型。必须使用验证集或测试集来计算这些指标,才能反映模型的真实泛化能力。在测试集上,随着模型复杂度过高,RSS会先下降后上升,形成一条U型曲线,其最低点对应最优的模型复杂度。

场景三:使用Statsmodels进行更详细的回归摘要

在实际数据分析中,我们经常使用statsmodels库,因为它能提供包含ESS、RSS、TSS在内的非常详细的回归结果摘要。

import statsmodels.api as sm

# 为X添加常数项(截距)
X_with_const = sm.add_constant(X)  # 添加一列1,代表常数项

# 使用OLS(普通最小二乘法)进行拟合
ols_model = sm.OLS(y_true, X_with_const)
ols_results = ols_model.fit()

# 打印详细的摘要报告
print(ols_results.summary())

# 从结果对象中直接提取我们关心的指标
print("\n=== 从Statsmodels结果中提取 ===")
print(f"ESS (Explained Sum of Squares): {ols_results.ess:.2f}")
print(f"RSS (Residual Sum of Squares): {ols_results.ssr:.2f}") # 注意属性名是ssr
print(f"TSS (Total Sum of Squares): {ols_results.centered_tss:.2f}")
print(f"R-squared: {ols_results.rsquared:.4f}")
print(f"验证 TSS = ESS + RSS: {np.isclose(ols_results.centered_tss, ols_results.ess + ols_results.ssr)}")

Statsmodels的摘要表是一个信息宝库,除了我们讨论的平方和,它还提供了系数显著性检验(t检验)、模型整体显著性检验(F检验,其统计量基于ESS和RSS计算)、以及调整R²等众多指标。学会阅读这个摘要,是数据分析师的基本功。

5. 常见陷阱与最佳实践

在计算和使用ESS、RSS、TSS时,有一些细节需要注意,否则可能导致错误的结论。

陷阱1:混淆“中心化”与“非中心化”TSS 我们一直使用的公式 TSS = Σ(y_i - y_mean)² 被称为中心化的总平方和。它要求模型必须包含截距项。如果你的线性回归模型强制没有截距(即设置 fit_intercept=False),那么 TSS = ESS + RSS 这个等式可能不再成立,此时应该使用非中心化的TSS(Σ(y_i)²)来计算R²。Scikit-learn的 r2_score 函数有一个 force_finite 参数来处理这种情况,但在手动计算时要格外小心。

# 拟合一个无截距的模型(通常不推荐,除非你有很强的先验知识)
model_no_intercept = LinearRegression(fit_intercept=False)
model_no_intercept.fit(X_reshaped, y_true)
y_pred_no_int = model_no_intercept.predict(X_reshaped)

# 计算中心化TSS (可能不适用)
TSS_centered = np.sum((y_true - np.mean(y_true)) ** 2)
RSS_no_int = np.sum((y_true - y_pred_no_int) ** 2)
# 此时,TSS_centered 很可能不等于 ESS + RSS
print(f"无截距模型 - 中心化TSS: {TSS_centered:.2f}")
print(f"无截距模型 - RSS: {RSS_no_int:.2f}")
# 使用sklearn的r2_score,注意其行为
r2_no_int = r2_score(y_true, y_pred_no_int)
print(f"无截距模型 - R² (sklearn): {r2_no_int:.4f}")
# 这个R²可能为负数!表示模型比简单均值模型还差。

陷阱2:在时间序列或分组数据中误用 在时间序列回归中,TSS的计算可能涉及是否去趋势或去季节性的问题。在面板数据或分组数据中,计算组内和组间的ESS/RSS/TSS(即固定效应模型)是另一套更复杂的体系。确保你使用的平方和定义与你的分析目标一致。

最佳实践清单:

  • 始终可视化:在计算任何指标前,先绘制数据散点图和拟合线。肉眼观察能发现很多公式发现不了的问题,如非线性、异常值、异方差等。
  • 理解上下文:明确你的TSS是基于均值(中心化)还是基于零(非中心化)。在绝大多数包含截距的模型中,使用中心化版本。
  • 结合其他指标:不要孤立地看R²或平方和。结合:
    • 调整R²:惩罚模型复杂度,防止过拟合。
    • 均方误差(MSE)或均方根误差(RMSE)MSE = RSS / nRMSE = sqrt(MSE),它们与数据在同一量纲,更易于解释。
    • F统计量F = (ESS / p) / (RSS / (n-p-1)),其中p是特征数,用于检验模型整体显著性。
  • 在测试集上评估:前面已经强调过,训练集上的R²(基于训练集计算的TSS、ESS、RSS)是乐观的。真正的考验是在未见过的测试集上计算这些指标。
  • 使用成熟的库进行验证:在完成手动计算后,用像Statsmodels或Scikit-learn这样的权威库的结果进行交叉验证,确保计算无误。

手动实现一遍这些计算,再结合可视化,你对模型评估的理解会从“知道公式”深入到“理解其几何和统计意义”。下次当你的模型R²不理想时,你不会再只是感到沮丧,而是会本能地去检查是ESS太小(模型解释力弱)还是RSS太大(数据噪声大或模型欠拟合),并据此采取针对性的优化策略,例如寻找更有预测力的特征,或尝试更复杂的模型架构。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐