超越教科书:用Python重现R2为负的7个经典案例(附数据集)

超越教科书:用Python重现R2为负的7个经典案例(附数据集)

很多机器学习初学者第一次在sklearn中看到r2_score输出一个负数时,第一反应往往是“代码写错了”或者“库函数有bug”。毕竟,从统计学教科书上学到的知识告诉我们,决定系数R²衡量的是模型解释数据方差的比例,其值理应在0到1之间。然而,当你亲手在Jupyter Notebook里运行几行简单的代码,得到一个刺眼的负值时,那种认知被颠覆的瞬间,恰恰是深度学习理解的最佳契机。这篇文章不是要告诉你R²为什么“应该”在0到1之间,而是要带你亲手“制造”出负的R²,通过七个精心设计的、可复现的Python案例,让你从第一性原理层面理解这个指标的边界与陷阱。我们将完全在实践操作中展开,适合那些相信“眼见为实,手过为真”的学习者。

1. 重新认识R²:不止是“解释的方差”

在动手之前,我们需要暂时放下对R²的固有印象。sklearn.metrics.r2_score采用的公式,是通用性最强的定义之一:

R² = 1 - (残差平方和 / 总平方和)

其中:

  • 残差平方和 (RSS或SSE):模型预测值与真实值之差的平方和。sum((y_true - y_pred) ** 2)
  • 总平方和 (TSS或SST):真实值与其均值之差的平方和。sum((y_true - y_true.mean()) ** 2)

这个公式蕴含了一个关键前提:它是在与一个基线模型进行比较。这个基线模型通常被设定为“均值模型”,即无论输入是什么,总是预测目标变量的平均值(y_pred_baseline = y_true.mean())。均值模型的残差平方和恰好等于总平方和(TSS),因此其R²为0。

注意:sklearn官方文档明确指出:“最佳可能得分是1.0,并且它可能是负数(因为模型可能任意地更差)。一个总是预测y的期望值而忽略输入特征的常数模型,将得到0.0的R²分数。”

换句话说,R²衡量的是你的模型比“简单预测平均值”这个笨办法好多少。当你的模型预测结果比直接猜平均值还要离谱时,R²就会跌破0,进入负值区间。这通常发生在模型严重错误、或者在不恰当的评估方式下。

下面,我们进入实战环节。请确保你的环境已安装numpy, pandas, matplotlibscikit-learn

# 环境准备与基础导入
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import r2_score
print("所有库已就绪,R²冒险之旅开始!")

2. 案例一:当模型比“瞎猜平均值”还糟糕时

让我们从一个最直观的例子开始:用一个完全错误的常数去预测。

# 生成一个简单的线性趋势数据
np.random.seed(42)
X_demo = np.arange(100)
y_true = 2 * X_demo + 10 + np.random.normal(0, 5, 100) # y = 2x + 10 + 噪声

# 案例1A:使用均值模型(基线)
y_pred_baseline = np.full_like(y_true, fill_value=y_true.mean())
r2_baseline = r2_score(y_true, y_pred_baseline)
print(f"基线模型(预测均值)的 R²: {r2_baseline:.6f}") # 应该非常接近0

# 案例1B:使用一个极其糟糕的常数模型(比如,预测一个远离均值的值)
y_pred_terrible = np.full_like(y_true, fill_value=y_true.mean() + 1000) # 预测值比均值大1000
r2_terrible = r2_score(y_true, y_pred_terrible)
print(f“糟糕常数模型的 R²: {r2_terrible:.6f}”) # 将会是一个显著的负数

运行这段代码,你会看到第一个负R²诞生了。为什么?因为y_pred_terrible这个预测序列,它与真实值y_true的差距(RSS),远远大于真实值自身围绕其均值的波动(TSS)。根据公式 1 - (RSS/TSS),当RSS > TSS时,结果自然为负。

核心洞察:R²为负的第一个充分条件,就是你的模型在样本内的预测误差,比直接使用目标变量均值作为预测的误差还要大。这通常意味着模型完全失效。

3. 案例二:非线性关系强行用线性模型拟合

这是实践中导致负R²的常见原因之一。数据本身存在强烈的非线性关系(如二次、指数),而我们却固执地使用简单线性回归去拟合。

from sklearn.linear_model import LinearRegression

# 生成一个清晰的二次关系数据
np.random.seed(123)
X = np.linspace(-5, 5, 100)
y_true_quad = X ** 2 + np.random.normal(0, 1, 100) # y = x^2 + 噪声

# 尝试用线性模型拟合
X_reshaped = X.reshape(-1, 1)
model_linear = LinearRegression()
model_linear.fit(X_reshaped, y_true_quad)
y_pred_linear = model_linear.predict(X_reshaped)

r2_linear_on_quad = r2_score(y_true_quad, y_pred_linear)
print(f“用线性模型拟合二次数据的 R²: {r2_linear_on_quad:.6f}”)

# 可视化对比
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.scatter(X, y_true_quad, alpha=0.6, label='真实数据')
plt.plot(X, y_pred_linear, color='red', linewidth=2, label='线性拟合')
plt.axhline(y=y_true_quad.mean(), color='green', linestyle='--', label='数据均值')
plt.title(f'线性拟合R² = {r2_linear_on_quad:.4f}')
plt.legend()

# 作为对比,使用多项式回归(正确的模型)
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import make_pipeline
poly_model = make_pipeline(PolynomialFeatures(degree=2), LinearRegression())
poly_model.fit(X_reshaped, y_true_quad)
y_pred_poly = poly_model.predict(X_reshaped)
r2_poly = r2_score(y_true_quad, y_pred_poly)

plt.subplot(1, 2, 2)
plt.scatter(X, y_true_quad, alpha=0.6, label='真实数据')
plt.plot(X, y_pred_poly, color='orange', linewidth=2, label='二次多项式拟合')
plt.axhline(y=y_true_quad.mean(), color='green', linestyle='--', label='数据均值')
plt.title(f'多项式拟合R² = {r2_poly:.4f}')
plt.legend()
plt.tight_layout()
plt.show()

观察左图,红色的线性拟合线几乎是一条水平线,它与数据点的距离(大误差)远大于数据点围绕其自身均值(绿线)的波动。此时线性模型不仅没有捕捉到趋势,反而引入了系统性偏差,其表现比“直接猜平均值”更差,R²为负。右图则展示了正确模型的威力。

4. 案例三:在测试集上评估一个未正确训练的模型

这是机器学习工作流中一个关键的陷阱。我们通常在训练集上拟合模型,然后在独立的测试集上评估其泛化能力。如果你用一个在完全不同分布的数据上训练出的模型去预测,R²很容易变负。

from sklearn.model_selection import train_test_split

# 生成两组不同分布的数据
np.random.seed(456)
# 训练集:有正斜率趋势
X_train = np.random.uniform(0, 10, 50)
y_train = 3 * X_train + 5 + np.random.normal(0, 2, 50)

# 测试集:有负斜率趋势(分布不同!)
X_test = np.random.uniform(0, 10, 30)
y_test = -2 * X_test + 20 + np.random.normal(0, 2, 30)

# 在训练集上拟合线性模型
model_mismatch = LinearRegression()
model_mismatch.fit(X_train.reshape(-1, 1), y_train)

# 在训练集上评估(通常表现良好)
y_pred_train = model_mismatch.predict(X_train.reshape(-1, 1))
r2_train = r2_score(y_train, y_pred_train)

# 在分布不同的测试集上评估(灾难!)
y_pred_test = model_mismatch.predict(X_test.reshape(-1, 1))
r2_test = r2_score(y_test, y_pred_test)

print(f“模型在训练集上的 R²: {r2_train:.4f}”)
print(f“模型在分布不同的测试集上的 R²: {r2_test:.4f}”)

# 创建对比表格
results_df = pd.DataFrame({
    ‘数据集’: [‘训练集’, ‘测试集’],
    ‘数据趋势’: [‘y ≈ 3x + 5’, ‘y ≈ -2x + 20’],
    ‘模型学到的趋势’: [‘y ≈ 3x + 5’, ‘y ≈ 3x + 5’],
    ‘R² 分数’: [r2_train, r2_test]
})
print(“\n模型评估结果对比:”)
print(results_df.to_string(index=False))

这个案例模拟了数据分布漂移训练-测试数据泄露的反面情况。模型在训练集上学到的是正相关,而测试集呈现的是负相关。模型在测试集上的预测不仅不准,而且错误是有方向的、系统性的,导致其误差远超测试集本身的方差,从而产生负R²。这提醒我们,一个在训练集上表现优异的模型,在现实世界中可能完全失效。

5. 案例四:极端离群值的“一票否决”威力

离群值对基于平方误差的指标(如MSE、R²)影响巨大。一个极端离群值可以轻易扭曲整个评估结果。

# 创建一份基本良好的数据
np.random.seed(789)
X_clean = np.random.randn(100)
y_clean = 0.8 * X_clean + np.random.randn(100) * 0.5

# 案例4A:在没有离群值的数据上拟合
model_clean = LinearRegression()
model_clean.fit(X_clean.reshape(-1, 1), y_clean)
y_pred_clean = model_clean.predict(X_clean.reshape(-1, 1))
r2_clean = r2_score(y_clean, y_pred_clean)

# 案例4B:引入一个极端离群值
X_with_outlier = np.append(X_clean, 10) # 在特征中加入一个极端值
y_with_outlier = np.append(y_clean, 100) # 在目标中加入一个对应的极端值

model_outlier = LinearRegression()
model_outlier.fit(X_with_outlier.reshape(-1, 1), y_with_outlier)
y_pred_with_outlier = model_outlier.predict(X_with_outlier.reshape(-1, 1))
r2_with_outlier = r2_score(y_with_outlier, y_pred_with_outlier)

print(f“无离群数据时的 R²: {r2_clean:.4f}”)
print(f“加入一个极端离群点后的 R²: {r2_with_outlier:.4f}”)

# 分析原因:计算离群点带来的误差放大效应
sst_clean = ((y_clean - y_clean.mean()) ** 2).sum()
sse_clean = ((y_clean - y_pred_clean) ** 2).sum()

sst_with = ((y_with_outlier - y_with_outlier.mean()) ** 2).sum()
sse_with = ((y_with_outlier - y_pred_with_outlier) ** 2).sum()

print(f“\n【误差分解】”)
print(f“无离群点: SST={sst_clean:.2f}, SSE={sse_clean:.2f}, SSE/SST={sse_clean/sst_clean:.4f}”)
print(f“有离群点: SST={sst_with:.2f}, SSE={sse_with:.2f}, SSE/SST={sse_with/sst_with:.4f}”)
print(f“离群点使SST增大了约 {((sst_with-sst_clean)/sst_clean)*100:.1f}%,但使SSE增大了约 {((sse_with-sse_clean)/sse_clean)*100:.1f}%”)

你会发现,加入一个离群点后,R²显著下降,甚至可能变为负数。这是因为离群点同时增大了SST(分母)和SSE(分子),但SSE的增幅往往远大于SST。模型为了“照顾”这个离群点,会扭曲对整个数据集的拟合,导致对其他“正常”数据点的预测也变差,从而使得整体SSE急剧膨胀。

6. 案例五:评估完全随机的噪声数据

这个案例旨在打破一个迷思:不是所有数据都能被模型解释。当因变量完全是随机噪声,与任何自变量都无关时,任何试图寻找规律的模型都是在“过拟合”噪声。

np.random.seed(1122)
# 特征X可以是任何有规律或无规律的数据
X_random = np.random.randn(200).reshape(-1, 1)
# 目标y是纯粹的随机噪声,与X无关
y_random_noise = np.random.randn(200)

# 尝试用线性模型去拟合
model_on_noise = LinearRegression()
model_on_noise.fit(X_random, y_random_noise)
y_pred_noise = model_on_noise.predict(X_random)

r2_on_noise = r2_score(y_random_noise, y_pred_noise)
print(f“用线性模型拟合纯随机噪声的 R²: {r2_on_noise:.6f}”)

# 多次实验,观察R²的分布
r2_list = []
for i in range(1000):
    y_temp = np.random.randn(200)
    model_temp = LinearRegression()
    model_temp.fit(X_random, y_temp) # 使用相同的X
    y_pred_temp = model_temp.predict(X_random)
    r2_list.append(r2_score(y_temp, y_pred_temp))

plt.hist(r2_list, bins=30, edgecolor='black', alpha=0.7)
plt.axvline(x=0, color='red', linestyle='--', label='R² = 0 (均值模型)')
plt.xlabel(‘R² Score’)
plt.ylabel(‘Frequency’)
plt.title(‘1000次实验:用线性模型拟合随机噪声的R²分布’)
plt.legend()
plt.show()

print(f“在1000次实验中,R²的平均值为:{np.mean(r2_list):.4f}”)
print(f“R²为负的比例是:{(np.array(r2_list) < 0).sum() / len(r2_list) * 100:.1f}%”)

这个实验非常有趣。你会发现,即使拟合纯噪声,得到的R²也可能是一个很小的正数(由于随机巧合),但有相当一部分实验会得到负的R²。这是因为线性模型强行从噪声中“学习”到了一个虚假的、微弱的模式,这个模式在样本内可能略微降低了误差(相对于均值),但更多时候,这种强行拟合引入的偏差反而增大了误差,使得模型表现比均值模型更差。这深刻地揭示了在数据没有真实信号时,模型复杂度的危险性。

7. 案例六:使用错误的评估方式——在训练集外计算“伪R²”

这是一个高级但易错的场景,涉及时间序列或需要严格区分的样本外预测。R²的定义依赖于计算TSS时使用的均值(y_true.mean())。这个均值必须是评估所用数据的真实均值。

# 模拟一个时间序列
np.random.seed(2024)
time = np.arange(100)
trend = 0.05 * time
seasonality = 5 * np.sin(2 * np.pi * time / 20)
noise = np.random.randn(100) * 2
y_ts = trend + seasonality + noise + 10

# 错误做法:用全部数据的均值来计算测试集的R²
train_size = 80
y_train_ts, y_test_ts = y_ts[:train_size], y_ts[train_size:]
# 假设我们有一个简单的预测(例如,使用最后已知值)
y_pred_naive = np.full_like(y_test_ts, fill_value=y_train_ts[-1]) # 朴素预测:用最后一个训练值

# 错误计算:使用全体数据(训练+测试)的均值作为基准
global_mean = y_ts.mean()
sst_wrong = ((y_test_ts - global_mean) ** 2).sum()
sse = ((y_test_ts - y_pred_naive) ** 2).sum()
r2_wrong = 1 - (sse / sst_wrong)

# 正确计算:使用测试集自身的均值作为基准
test_mean = y_test_ts.mean()
sst_correct = ((y_test_ts - test_mean) ** 2).sum()
r2_correct = 1 - (sse / sst_correct)

print(f“【时间序列样本外评估】”)
print(f“朴素预测值: {y_pred_naive[0]:.2f}”)
print(f“测试集真实均值: {test_mean:.2f}”)
print(f“全体数据均值: {global_mean:.2f}”)
print(f“使用错误基准(全局均值)计算的 R²: {r2_wrong:.4f}”)
print(f“使用正确基准(测试集均值)计算的 R²: {r2_correct:.4f}”)

你会发现,两种计算方式得出的R²可能天差地别,甚至符号相反。sklearn.metrics.r2_score(y_true, y_pred)函数内部会自动使用y_true的均值来计算TSS,因此只要传入正确的y_true,它总是正确的。 这个案例的“错误”在于手动计算时选错了基准均值。它警示我们,当进行复杂的交叉验证或滚动预测时,必须确保每个评估片段都使用其自身的目标均值来计算基准。

8. 案例七:过拟合与数据泄露的“负”面教材

最后,我们看一个在复杂模型中更隐蔽的情况。当模型在训练集上严重过拟合,以至于学到了数据中的噪声和特定样本的 idiosyncrasies 时,它在未见过的数据上可能产生灾难性预测。

from sklearn.preprocessing import PolynomialFeatures
from sklearn.model_selection import cross_val_score

# 生成一份有轻微非线性但主要仍是线性的数据
np.random.seed(333)
X_complex = np.random.uniform(-3, 3, 50)
y_complex = 0.7 * X_complex + 0.3 * (X_complex**2) + np.random.randn(50)

# 使用一个极高阶的多项式特征,故意制造过拟合
degree = 20
poly = PolynomialFeatures(degree=degree, include_bias=False)
X_poly_high = poly.fit_transform(X_complex.reshape(-1, 1))

model_overfit = LinearRegression()
model_overfit.fit(X_poly_high, y_complex)
y_pred_train_overfit = model_overfit.predict(X_poly_high)
r2_train_overfit = r2_score(y_complex, y_pred_train_overfit)

# 在全新的、来自同一分布的数据上评估
X_new = np.random.uniform(-3, 3, 50)
y_new = 0.7 * X_new + 0.3 * (X_new**2) + np.random.randn(50)
X_new_poly = poly.transform(X_new.reshape(-1, 1)) # 使用相同的特征转换
y_pred_new_overfit = model_overfit.predict(X_new_poly)
r2_test_overfit = r2_score(y_new, y_pred_new_overfit)

print(f“使用{degree}阶多项式在训练集上的 R²: {r2_train_overfit:.6f} (严重过拟合,接近1)”)
print(f“同一模型在全新测试集上的 R²: {r2_test_overfit:.6f}”)

# 对比一个合适复杂度(2阶)的模型
poly_proper = PolynomialFeatures(degree=2, include_bias=False)
X_poly_proper = poly_proper.fit_transform(X_complex.reshape(-1, 1))
model_proper = LinearRegression()
model_proper.fit(X_poly_proper, y_complex)

y_pred_test_proper = model_proper.predict(poly_proper.transform(X_new.reshape(-1, 1)))
r2_test_proper = r2_score(y_new, y_pred_test_proper)
print(f“\n作为对比,使用2阶多项式在全新测试集上的 R²: {r2_test_proper:.6f}”)

过拟合的模型在训练集上R²可以非常高,但在测试集上,因为它学习的是训练数据中不具泛化性的噪声模式,其预测可能会系统地偏离真实值,导致误差极大,R²变为负数。这提醒我们,不能只看训练集性能,必须通过严格的样本外验证来监控模型。

动手完成这七个案例,你对R²的理解应该不再局限于教科书上的[0,1]区间。负的R²不是一个需要被“修复”的bug,而是一个强烈的红色警报。它明确告诉你:当前模型在当前数据上的表现,甚至不如一个最简单的基准模型(预测平均值)。这个信号迫使你去检查数据质量、模型假设、训练-测试划分是否合理,或者你的任务是否本身就缺乏可预测的信号。下次再看到负的R²时,希望你的第一反应不再是困惑,而是兴奋——因为你知道,深度排查和理解的时刻到了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值