波士顿房价预测numpy版本
# 任务内容
波士顿房价数据集(Boston Housing Dataset):
该数据集最初由美国波士顿地区的房屋信息统计而来,最早用于研究空气质量对房价的影响,是机器学习的经典回归数据集。
文件名:housing.csv
数据量:共506条样本,每条样本对应一处房产
特征数:13个特征+ 1个目标值(房价)
分隔符:空格分隔,无表头
# 任务定义与数据洞察
任务类型:回归问题。
- 区别:分类问题是输出“是猫还是狗”(离散值),回归问题是输出“房价是多少”(连续值)。
- 影响:这决定了我们的输出层激活函数不能用 Sigmoid(它会把结果压缩在0-1之间,不适合预测具体金额),也不能用交叉熵损失函数。
数据难点:量纲不一致。
- 现象:特征 CRIM(犯罪率)可能是 0.006,而 TAX(房产税)可能是 600。
- 后果:如果不处理,计算梯度时,大数值的特征会主导梯度的方向,导致模型“偏科”,甚至因为数值过大导致计算溢出(NaN)。
# 数据预处理(最关键的一步)
# 步骤 A:读取与打乱
- 操作:使用 np.loadtxt 读取,随即使用 np.random.permutation 生成随机索引打乱数据。
为什么必须打乱?
- 原始数据往往是按某种顺序采集的(比如按区域、按时间)。如果直接切分,可能导致训练集里全是“低房价区”,测试集里全是“高房价区”。
- 原理:机器学习假设数据是独立同分布的。打乱是为了保证训练集和测试集的统计分布一致,模拟真实世界的随机性。
# 步骤 B:划分数据集
操作:通常按 8:2 或 7:3 划分。前 80% 为训练集,后 20% 为测试集。
为什么要留测试集?
- 防止“死记硬背”(过拟合):神经网络记忆力很好,如果让它看所有数据,它可能会记住每一个样本的噪声。
- 原理:训练集是“平时作业”,用来调整参数;测试集是“期末考试”,用来验证模型是否真的学到了规律(泛化能力)。测试集的数据在训练过程中绝对不能被模型看到。
# 步骤 C:归一化
- 操作:使用 Min-Max 标准化将所有特征缩放到 [0, 1] 区间。公式:

核心避坑点
- 错误做法:把所有数据混在一起算最大值最小值,然后再切开。
- 正确做法:只在训练集上计算Xmin和Xmax,然后用这两个值去处理测试集。
为什么?:在真实场景中,新来的数据(测试集)我们是不知道其全局分布的。我们必须依赖从历史数据(训练集)中学到的规则来处理新数据。如果在测试集上重新计算均值方差,这叫“数据泄露”,会导致评估结果虚高。
# 网络架构设计
结构选择:输入层 -> 隐藏层 -> 输出层
输入层:13个神经元(对应13个特征)。
隐藏层:建议 10~20 个神经元。
- 激活函数:ReLU ( f(x)=max(0,x))。
- 为什么选 ReLU?:相比 Sigmoid,ReLU 在正区间导数恒为 1,能有效缓解梯度消失问题,让深层网络也能训练得动,且计算速度极快(不需要做指数运算)。
为什么隐藏层建议设置 10~20 个神经元?
这其实是在寻找“学习能力”与“数据量”之间的平衡点。
输入端决定了下限(至少需要 13 个)
- 你的输入特征有 13 个(CRIM, ZN, INDUS...)。
- 如果隐藏层的神经元少于 13 个(比如只有 5 个),这就相当于强行把 13 维的信息压缩到 5 维。这就好比让你用 5 个字去概括一篇 1000 字的文章,必然会丢失大量关键信息(这叫“欠拟合”或“瓶颈效应”)。所以,通常第一层隐藏层的宽度至少要能容纳输入信息的维度。
数据量决定了上限(不能太多)
- 波士顿数据集只有 506 条数据,这在深度学习里属于极小样本。
- 如果你设置 100 个神经元,参数数量会爆炸式增长。模型会拥有几万个参数去拟合这 500 个点。结果就是:模型把每个点的噪声都背下来了(过拟合),训练误差极低,但一遇到新数据就预测不准。
- 经验法则:对于这种几百条数据的小表格任务,隐藏层神经元数量通常是输入特征数的 0.5倍 到 2倍 之间。所以 13×1≈13 ,取 10~20 是最稳妥的“黄金区间”。
- 输出层:1个神经元。
- 激活函数:无(Linear)。
- 为什么不用激活函数?:因为我们要预测的是任意范围的房价。如果加了 Sigmoid,输出就被锁死在 0-1 之间了;如果加 ReLU,负数房价就预测不出来了。直接输出线性值最合适。
# 核心算法推导
# 前向传播
- 公式:Z=W⋅X+b ,然后 A=ReLU(Z)。
- 这就是一个不断的“加权求和”再“非线性变换”的过程。矩阵乘法 W⋅X 实现了所有神经元的同时计算,这是 Numpy 比 Python for 循环快几百倍的原因。
# 损失函数
选择:均方误差。公式

为什么选 MSE?:它是回归问题的标准配置。它对误差取了平方,意味着大的误差会被放大惩罚(差2倍的误差,Loss会大4倍),这能迫使模型优先修正那些错得离谱的预测。
# 反向传播
目标:求出 Loss 对每个权重 W 的偏导数 ∂L/∂W
链式法则通俗解释:想象你在传话游戏。输出层的误差是“源头”,我们要把这个误差一层层往回传。
每一层收到误差后,都要问自己两个问题:
- “我这一层的激活函数(ReLU)当时有没有‘阻断’信号?”(如果是负数输入,导数为0,梯度直接断掉,不再往回传)。
- “我的输入X 是大是小?”(输入越大,对结果影响越大,承担的“锅”也就是梯度就越大)。
最终算出梯度,告诉权重W :“你该往哪个方向走,走多少步,才能减小误差”。
# 训练策略
# 学习率
- 设置:通常是一个很小的数,如 0.01 或 0.001。
- 比喻:下山时的步长。
- 太大:一步跨过了谷底,甚至在两边反复横跳,永远不收敛(Loss 爆炸)。
- 太小:像蚂蚁搬家,走几万年也走不到谷底(训练太慢,容易卡在局部最优)。
# 迭代次数
- 观察:不要设死一个数字。要画一张图,横轴是 Epoch(轮数),纵轴是 Loss。
- 判断标准:当 Loss 曲线变平,不再明显下降时,就可以停止了。
# 完整代码
import numpy as np
# ==========================================
# 1. 辅助函数定义 (激活函数与损失函数)
# ==========================================
def relu(z):
"""ReLU 激活函数: max(0, z)"""
return np.maximum(0, z)
def relu_derivative(z):
"""ReLU 的导数: z>0 时为 1,否则为 0"""
return (z > 0).astype(float)
def mse_loss(y_pred, y_true):
"""均方误差损失 (MSE)"""
return np.mean((y_pred - y_true) ** 2)
# ==========================================
# 2. 数据加载与预处理
# ==========================================
# 读取数据 (假设文件名为 housing.csv,空格分隔,无表头)
# 如果文件名不同,请修改此处
try:
data = np.loadtxt('housing.csv')
except FileNotFoundError:
print("错误:未找到 housing.csv 文件,请确保文件在当前目录下。")
exit()
# --- 数据打乱 (非常重要!防止数据有序导致分布不均) ---
np.random.seed(42) # 固定随机种子,保证结果可复现
indices = np.random.permutation(data.shape[0])
data = data[indices]
# --- 划分特征(X)和目标值(Y) ---
X = data[:, :-1] # 前13列是特征
Y = data[:, -1:] # 最后1列是房价 (保持二维形状 [N, 1])
# --- 划分训练集和测试集 (80% 训练, 20% 测试) ---
split_idx = int(X.shape[0] * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
Y_train, Y_test = Y[:split_idx], Y[split_idx:]
# --- 归一化 (Min-Max Scaling) ---
# 核心原则:必须用训练集的统计量来处理测试集,防止数据泄露
x_min, x_max = X_train.min(axis=0), X_train.max(axis=0)
y_min, y_max = Y_train.min(axis=0), Y_train.max(axis=0)
# 避免除以0的情况
x_range = x_max - x_min
x_range[x_range == 0] = 1e-8
y_range = y_max - y_min
if y_range[0] == 0: y_range[0] = 1e-8
X_train_norm = (X_train - x_min) / x_range
X_test_norm = (X_test - x_min) / x_range
Y_train_norm = (Y_train - y_min) / y_range
Y_test_norm = (Y_test - y_min) / y_range
print(f"数据加载完成: 训练集 {X_train_norm.shape}, 测试集 {X_test_norm.shape}")
# ==========================================
# 3. 神经网络参数初始化
# ==========================================
input_dim = 13 # 输入特征数
hidden_dim = 20 # 隐藏层神经元数 (建议 10~20)
output_dim = 1 # 输出层神经元数
learning_rate = 0.05 # 学习率 (回归任务通常比分类任务小一点)
# 使用 He Initialization 初始化权重 (配合 ReLU 效果最好)
# W1: [13, 20], b1: [1, 20]
W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim)
b1 = np.zeros((1, hidden_dim))
# W2: [20, 1], b2: [1, 1]
W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim)
b2 = np.zeros((1, output_dim))
# ==========================================
# 4. 训练循环 (前向传播 + 反向传播)
# ==========================================
epochs = 5000 # 训练轮数
for epoch in range(epochs):
# --- A. 前向传播 (Forward Pass) ---
# 第一层: Linear -> ReLU
Z1 = X_train_norm.dot(W1) + b1 # [N, 20]
A1 = relu(Z1) # [N, 20]
# 第二层: Linear -> Output (回归问题输出层不用激活函数)
Z2 = A1.dot(W2) + b2 # [N, 1]
Y_pred = Z2 # 预测值
# 计算损失
loss = mse_loss(Y_pred, Y_train_norm)
# --- B. 反向传播 (Backward Pass) ---
# 这里的推导是基于 MSE Loss = mean((y_pred - y_true)^2)
# dL/dY_pred = 2 * (Y_pred - Y_true) / N
m = X_train_norm.shape[0]
dZ2 = (2.0 / m) * (Y_pred - Y_train_norm) # [N, 1]
# 更新 W2, b2 的梯度
dW2 = A1.T.dot(dZ2) # [20, 1]
db2 = np.sum(dZ2, axis=0, keepdims=True) # [1, 1]
# 误差回传到隐藏层
dA1 = dZ2.dot(W2.T) # [N, 20]
dZ1 = dA1 * relu_derivative(Z1) # [N, 20] (点乘导数)
# 更新 W1, b1 的梯度
dW1 = X_train_norm.T.dot(dZ1) # [13, 20]
db1 = np.sum(dZ1, axis=0, keepdims=True) # [1, 20]
# --- C. 参数更新 (Gradient Descent) ---
W1 -= learning_rate * dW1
b1 -= learning_rate * db1
W2 -= learning_rate * dW2
b2 -= learning_rate * db2
# 打印进度
if epoch % 200 == 0:
print(f"Epoch {epoch}: Loss = {loss:.6f}")
# ==========================================
# 5. 模型评估 (在测试集上验证)
# ==========================================
# 测试集前向传播
Z1_test = X_test_norm.dot(W1) + b1
A1_test = relu(Z1_test)
Y_pred_test_norm = A1_test.dot(W2) + b2
# 反归一化 (把预测值还原成真实的房价金额)
Y_pred_test = Y_pred_test_norm * y_range + y_min
# 计算真实尺度的 MSE
test_loss = mse_loss(Y_pred_test, Y_test)
print("\n===============================")
print(f"最终测试集 MSE (真实房价尺度): {test_loss:.2f}")
print(f"最终测试集 RMSE (平均误差金额): ${np.sqrt(test_loss):.2f}k")
print("===============================")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# 代码解析
# 第一部分:辅助函数定义
这部分定义了模型中会用到的核心数学函数。
relu(z):
- 代码: return np.maximum(0, z)
- 解释: 这是 ReLU (Rectified Linear Unit) 激活函数。它的作用是引入非线性。如果输入 z 大于 0,就返回 z;如果 z 小于等于 0,就返回 0。这就像一个开关,只让正向信号通过。
relu_derivative(z):
- 代码: return (z > 0).astype(float)
- 解释: 这是 ReLU 函数的导数,用于反向传播。如果输入 z 大于 0,导数为 1;否则为 0。(z > 0) 会生成一个布尔数组,.astype(float) 将其转换为浮点数(True -> 1.0, False -> 0.0)。
mse_loss(y_pred, y_true):
- 代码: return np.mean((y_pred - y_true) ** 2)
- 解释: 这是均方误差 (Mean Squared Error, MSE) 损失函数,用于衡量模型预测值 y_pred 和真实值 y_true 之间的差距。差距越小,Loss 值越低,代表模型预测得越准。
# 第二部分:数据加载与预处理
这是整个机器学习流程中最关键的一步,数据质量直接决定模型上限。
- 加载数据:
- data = np.loadtxt('housing.csv'): 从 housing.csv 文件中加载数据。np.loadtxt 默认按空格分隔,将数据读入一个 Numpy 数组。
打乱数据:
- np.random.seed(42): 设置随机种子,确保每次运行代码时生成的随机数都一样,保证结果可复现。
- indices = np.random.permutation(data.shape[0]): 生成一个从 0 到数据总行数(506)的随机排列索引。
- data = data[indices]: 使用这个随机索引对原始数据进行重新排序。这是为了防止数据本身有某种顺序(如按房价高低排列),导致划分训练集和测试集时分布不均。
划分特征(X)和目标值(Y):
- X = data[:, :-1]: 取出所有行、除最后一列外的所有列作为特征 X (形状为 [506, 13])。
- Y = data[:, -1:]: 取出所有行、最后一列作为目标值(房价)Y。-1: 的写法是为了保持 Y 是一个二维数组(形状为 [506, 1]),方便后续矩阵运算。
划分训练集和测试集:
- split_idx = int(X.shape[0] * 0.8): 计算划分点,取 80% 的数据作为训练集。
- X_train, X_test = X[:split_idx], X[split_idx:]: 将特征数据切分为训练集和测试集。
- Y_train, Y_test = Y[:split_idx], Y[split_idx:]: 将目标值数据切分为训练集和测试集。
归一化 (Min-Max Scaling):
- 核心原则: 只用训练集的统计信息(最大值、最小值)来处理测试集,模拟真实场景中用历史数据预测未来数据的情况,防止“数据泄露”。
- x_min, x_max = X_train.min(axis=0), X_train.max(axis=0): 分别计算训练集每个特征的最小值和最大值。
- x_range = x_max - x_min: 计算每个特征的范围。
- x_range[x_range == 0] = 1e-8: 防御性编程。如果某个特征在所有样本中值都一样,x_range 就会是 0,导致后续除法出错。这里将其替换为一个极小值。
- X_train_norm = (X_train - x_min) / x_range: 对训练集特征进行归一化,将所有特征值缩放到 [0, 1] 区间。
- X_test_norm = (X_test - x_min) / x_range: 关键! 使用训练集的 x_min 和 x_range 来处理测试集。
- Y_train_norm 和 Y_test_norm 的计算同理,对房价目标值也进行归一化。
# 第三部分:神经网络参数初始化
这里定义了网络结构并初始化了权重(W)和偏置(b)。
定义维度:
- input_dim = 13: 输入层有 13 个神经元,对应 13 个特征。
- hidden_dim = 20: 隐藏层有 20 个神经元。
- output_dim = 1: 输出层有 1 个神经元,对应预测的房价。
初始化权重 (He Initialization):
- W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim):
- np.random.randn(...): 生成一个符合标准正态分布的随机矩阵。
- np.sqrt(2.0 / input_dim): 这是 He 初始化方法,专门为 ReLU 激活函数设计。它根据输入神经元的数量来缩放随机权重的方差,可以有效防止梯度在深层网络中消失或爆炸,让训练更稳定。
- W2 的初始化同理。
初始化偏置:
- b1 = np.zeros((1, hidden_dim)): 将偏置初始化为 0。偏置可以初始化为 0,因为权重的随机性已经打破了网络的对称性。
# 第四部分:训练循环 (核心)
这是模型“学习”的过程,包含前向传播、计算损失、反向传播和参数更新四个步骤。
- 前向传播 (Forward Pass): 数据从输入层流向输出层,得到预测值。
- Z1 = X_train_norm.dot(W1) + b1: 计算隐藏层的线性输出。X_train_norm (N, 13) 与 W1 (13, 20) 做矩阵乘法,得到 (N, 20) 的矩阵,再加上偏置 b1。
- A1 = relu(Z1): 将线性输出 Z1 通过 ReLU 激活函数,得到隐藏层的激活输出 A1。
- Z2 = A1.dot(W2) + b2: 计算输出层的线性输出。A1 (N, 20) 与 W2 (20, 1) 做矩阵乘法,得到 (N, 1) 的预测值。
- Y_pred = Z2: 因为是回归问题,输出层不使用激活函数,直接将 Z2 作为最终预测值。
- loss = mse_loss(Y_pred, Y_train_norm): 计算当前预测值与真实值之间的均方误差。
- 反向传播 (Backward Pass): 根据损失,从后往前计算每个参数的梯度(即损失函数对参数的偏导数)。
- m = X_train_norm.shape[0]: 获取训练样本数量 N。
- dZ2 = (2.0 / m) * (Y_pred - Y_train_norm): 计算损失对 Z2 的梯度。这是 MSE 损失函数对 Y_pred 求导的结果。
- dW2 = A1.T.dot(dZ2): 计算损失对 W2 的梯度。根据链式法则,dW2 = dZ2 * A1。在矩阵形式下,是 A1 的转置乘以 dZ2。
- db2 = np.sum(dZ2, axis=0, keepdims=True): 计算损失对 b2 的梯度。偏置的梯度是其对应误差项在所有样本上的和。
- dA1 = dZ2.dot(W2.T): 将误差从输出层反向传播到隐藏层,计算损失对 A1 的梯度。
- dZ1 = dA1 * relu_derivative(Z1): 计算损失对 Z1 的梯度。这里用到了逐元素乘法 *,因为要乘上 ReLU 的导数。
- dW1 = X_train_norm.T.dot(dZ1): 计算损失对 W1 的梯度。
- db1 = np.sum(dZ1, axis=0, keepdims=True): 计算损失对 b1 的梯度。
- 参数更新 (Gradient Descent): 使用计算出的梯度来更新权重和偏置,让模型在下一次预测时误差更小。
- W1 -= learning_rate * dW1: 沿着梯度的反方向更新 W1。learning_rate 控制更新的步长。
- b1, W2, b2 的更新同理。
# 第五部分:模型评估
训练完成后,在从未见过的测试集上评估模型的泛化能力。
- 测试集前向传播:
- 使用训练好的 W1, b1, W2, b2 参数,对 X_test_norm 进行一次前向传播,得到归一化后的预测房价 Y_pred_test_norm。这个过程和训练时的前向传播完全一样,但没有反向传播和参数更新。
反归一化:
Y_pred_test = Y_pred_test_norm * y_range + y_min: 将模型输出的归一化预测值,通过逆变换还原成真实的房价金额。这里使用的 y_range 和 y_min 是最初从训练集计算出来的。计算最终误差:
- test_loss = mse_loss(Y_pred_test, Y_test): 在真实的房价尺度上计算测试集的 MSE。
- np.sqrt(test_loss): 计算均方根误差 (RMSE),它的单位和房价一致(千美元),更直观。例如,RMSE 为 4.72 意味着模型预测的房价平均偏差约为 4720 美元。
# 完整数据流向总结
- 数据准备: housing.csv -> 打乱 -> 划分 (X_train, X_test, Y_train, Y_test) -> 归一化 (X_train_norm, X_test_norm, Y_train_norm, Y_test_norm)。
- 模型训练 (循环):
- 前向: X_train_norm -> (W1, b1) -> Z1 -> ReLU -> A1 -> (W2, b2) -> Y_pred。
- 计算损失: Y_pred vs Y_train_norm -> loss。
- 反向: loss -> 计算 dW2, db2, dW1, db1。
- 更新: W1, b1, W2, b2 = W, b - learning_rate * dW, db。
- 模型评估:
- 前向: X_test_norm -> (训练好的 W1, b1, W2, b2) -> Y_pred_test_norm。
- 还原: Y_pred_test_norm -> 反归一化 -> Y_pred_test (真实房价)。
- 计算指标: Y_pred_test vs Y_test -> test_loss (MSE), RMSE。