前言
这个标题把两件相关性不大的事情凑在了一起:单因素线性拟合属于统计建模,地理编码属于空间数据处理。它们共同点是「都可归到数据分析的大筐里」,但方法论完全不同。本文分开讲,讲清各自的原理和边界。
先说清楚两件事的定位:
- 单因素线性拟合:研究一个自变量和一个因变量之间的直线关系。它的原理是最小二乘法,可以只用 Python 标准库手写,不依赖任何第三方库。
- 地理编码(geocoding):把「地址」翻译成「经纬度」坐标。这不是数学问题,而是数据问题——标准库做不了,必须依赖第三方服务或离线地址库。
第三个必须提前声明的:本文不给出任何「拟合优度 0.9x」这类数字。拟合优度是数据的性质,不是算法的性质,脱离具体数据谈它是没有意义的。本文只讲怎么算、怎么读。本机无 Python 解释器,代码为逐行推演,未经运行。
还有一个和「求平均」直接相关的版本陷阱必须提前说:Python 2.7 已于 2020 年 1 月 1 日停止维护,而在 Python 2 里整数除法是向下取整的——3 / 2结果等于1,不是1.5。如果照搬老代码用sum(xs) / len(xs)求均值,且数据全是整数,均值会被悄悄截断,拟合结果随之出错。Python 3 的/一定返回浮点数,整除要用//。
一、单因素线性拟合要解决什么
「单因素」指只有一个自变量 x。模型是:
y = a * x + b任务是根据一堆观测点,找出最合适的 a(斜率)和 b(截距)。所谓「最合适」,需要一个标准,最常用、也是本文采用的标准是最小二乘:让所有点的残差平方和最小。
- 残差(residual):观测值减去模型预测值,即
y_i - (a*x_i + b)。 - 残差平方和:把每个残差平方后加总。平方的作用有两个:一是让正负偏差不互相抵消,二是让大偏差受到更重的「惩罚」。
二、最小二乘法的原理
对y = a*x + b,使残差平方和最小的 a 和 b 有闭式解(不需要迭代):
a = Σ((x_i - x̄)(y_i - ȳ)) / Σ((x_i - x̄)²)
b = ȳ - a * x̄其中 x̄ 和 ȳ 分别是 x、y 的算术平均值。
对残差平方和分别关于 a 和 b 求偏导、令其为 0,就能解出上面两式。这里不展开推导,记住两条结论即可:
- 解是唯一的(只要分母不为 0)。
- 分母
Σ((x_i - x̄)²)为 0 时无解——这表示所有 x 都相同,一条竖线上的一堆点,无法用y = a*x + b拟合。这是实际数据里必须防的情况。
三、纯标准库手写拟合
下面用标准库实现上面两个公式。没有引入任何第三方库:
# 适用于 Python 3.8+
from statistics import mean
def fit_line(xs, ys):
"""返回 (斜率, 截距)。要求两个序列等长且至少两个点。"""
if len(xs) != len(ys):
raise ValueError("xs 与 ys 长度必须相同")
if len(xs) < 2:
raise ValueError("至少需要两个点才能拟合一条直线")
xbar = mean(xs)
ybar = mean(ys)
sxx = sum((x - xbar) ** 2 for x in xs)
sxy = sum((x - xbar) * (y - ybar) for x, y in zip(xs, ys))
if sxx == 0:
# 所有 x 相同,直线无定义(分母为 0)
raise ValueError("所有 x 相同,无法拟合直线")
slope = sxy / sxx
intercept = ybar - slope * xbar
return slope, intercept逐点说明:statistics.mean是标准库函数,返回算术平均值;zip(xs, ys)把两个序列配对;sxx == 0的检查对应上一节说的「分母为 0」。返回的slope、intercept就是 a、b。
用它做预测:
# 适用于 Python 3.8+
def predict(x, slope, intercept):
return slope * x + intercept
xs = [1.0, 2.0, 3.0, 4.0]
ys = [3.0, 5.0, 7.0, 9.0] # 恰好是 y = 2x + 1
slope, intercept = fit_line(xs, ys)
print(slope, intercept) # 2.0 1.0
print(predict(10.0, slope, intercept)) # 21.0这组数据是人为构造成完全共线的(y = 2x + 1精确成立),所以 a 正好是 2、b 正好是 1。用这种「结果可以手算验证」的数据,是检查自己实现有没有写错的标准做法——注意,这是公式自洽性检查,不是任何真实数据的拟合结果。
四、残差与 R² 怎么读
4.1 残差
预测之后,逐点算残差能看出模型哪里不合适:
# 适用于 Python 3.8+
def residuals(xs, ys, slope, intercept):
return [y - (slope * x + intercept) for x, y in zip(xs, ys)]看残差有两个用途:一是看是否有系统性模式(比如残差随着 x 增大而单调变大,说明直线模型不合适,可能其实是曲线关系);二是找异常点(某点残差远大于其他点)。
4.2 决定系数 R²
R²(决定系数,coefficient of determination)的定义:
R² = 1 - SS_res / SS_tot
SS_res = Σ(y_i - ŷ_i)² 残差平方和
SS_tot = Σ(y_i - ȳ)² 总平方和读法:
| R² 的值 | 含义 |
|---|
| 接近 1 | 模型解释了大部分的 y 变异 |
| 接近 0 | 模型不比「直接用均值预测」强多少 |
| 等于 1 | 所有点恰好落在直线上(完全共线) |
| 小于 0 | 模型比「用均值预测」还差(可能用错模型) |
要强调的是:R² 高不代表模型正确。它只衡量线性拟合在这批数据上的贴合度,不能说明 x 和 y 有因果关系,也不能说明模型在新数据上同样有效。用同一批数据里挑出来的「最高 R²」当结论,是典型的数据泄漏。
# 适用于 Python 3.8+
def r_squared(xs, ys, slope, intercept):
ybar = mean(ys)
ss_res = sum((y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys))
ss_tot = sum((y - ybar) ** 2 for y in ys)
if ss_tot == 0:
raise ValueError("y 全相同,R² 无定义") # 分母为 0
return 1 - ss_res / ss_totsstot == 0的处理对应「所有 y 都相同」的情况——此时总平方和为 0,R² 无定义,必须显式挡掉,否则会除零。
五、地理编码是什么
5.1 定义
地理编码是把地址(如「某市某区某路 1 号」)转换成经纬度坐标的过程。它的逆过程叫逆地理编码,是把经纬度转回地址。
5.2 为什么标准库做不了
因为地址不是数学对象。同一栋楼,可能有「XX 路 1 号」「XX 路一号」「XX 路与 YY 路交叉口」等多种写法,要判断它们是同一个地方,需要一个地址到坐标的对照数据集。这个数据集要么来自第三方在线服务,要么来自离线地址库。
| 方案 | 需要什么 | 特点 |
|---|
| 在线地理编码服务 | 第三方服务 + 网络 | 覆盖广,有配额与条款限制 |
| 离线地址库 | 本地数据集 | 无网络依赖,但体积大、需自己维护 |
| 自行维护对照表 | 人工整理的小表 | 只适合极小范围、固定地址 |
本文不给出任何具体服务商的调用代码——服务的使用方式、参数、配额政策请以该服务官方文档为准,不要照搬记忆里的写法。这里只讲通用注意事项。
5.3 三个必须知道的坑
- 坐标系不统一。同一个位置在不同坐标系下有不同数值。国际通用的 GPS 原始坐标是一种坐标系,某些国家或地区还有自己的加密坐标系。跨数据源比对坐标前,必须先确认坐标系是否一致。
- 服务有配额和条款。在线地理编码通常有频率和总量限制,批量处理要限速,并遵守服务条款。
- 结果可能有歧义。模糊地址可能返回多个候选,需要人工确认;把模糊结果当成精确坐标用,误差可能非常大。
一次地理编码的典型流程(伪代码,不含任何具体服务 API):
# 适用于 Python 3.8+(纯流程示意)
def geocode_batch(addresses, geocoder, delay=1.0):
"""把地址批量转成坐标。geocoder 是调用具体服务的函数,见其官方文档。"""
import time
results = {}
for addr in addresses:
try:
results[addr] = geocoder(addr) # 返回 (纬度, 经度) 或 None
except Exception as exc: # 具体异常类型以所用服务为准
results[addr] = None
print("失败:", addr, exc)
time.sleep(delay) # 限速,尊重服务配额
return results注意这段里geocoder是注入进来的函数,不是具体实现——这正是把「业务逻辑」和「依赖具体服务」分开的写法。delay的存在是为了尊重服务配额,不是可选装饰。
常见坑点
- x 全相同还硬算斜率
❌fit_line([2,2,2], [1,2,3])分母为 0,抛ZeroDivisionError。
✅ 先检查sxx == 0,给出明确报错。
- 把 R² 当「模型正确性」的证据
❌ 「R² 高,说明 x 导致 y」。
✅ R² 只衡量拟合贴合度,不表明因果,也不保证新数据上有效。
- y 全相同时算 R²
❌ss_tot为 0,除零报错。
✅ 先判断ss_tot == 0,R² 此时无定义。
- 用残差均值判断拟合好坏
❌ 看残差平均值约等于 0 就说拟合好——最小二乘下残差和本来就近似为 0(对含截距模型成立),这个指标恒成立,没有区分力。
✅ 要看残差平方和或残差的分布模式。
- 跨坐标系直接比较经纬度
❌ 把 A 服务返回的坐标和 B 数据集的坐标直接相减求距离,误差大得离谱。
✅ 先统一坐标系,再做空间计算。
- 无视地理编码服务的配额
❌ 循环里不加间隔地调服务,触发限流被封。
✅ 加限速与重试退避,遵守服务条款。
- 把模糊地址当成精确坐标
❌ 只写了城市名的地址,返回的坐标被当成具体位置。
✅ 检查返回的精确度信息,模糊结果标记出来人工确认。
总结
| 主题 | 关键点 |
|---|
| 模型 | y = a*x + b,两参数 |
| 原理 | 最小二乘,有闭式解,不需迭代 |
| 手写 | statistics.mean+ 两个求和,纯标准库 |
| 边界 | x 全相同、点数不足、y 全相同都要显式挡掉 |
| 残差 | 看模式与异常点,别看均值 |
| R² | 越接近 1 贴合越好,但不等于模型正确 |
| 地理编码 | 地址转坐标,需第三方服务或离线库,注意坐标系与配额 |
线性拟合的迷人之处在于简单:两个公式、几行代码,标准库就能跑。但正因为简单,才更要守住边界条件——分母为零、样本不足、R² 被过度解读,都是这条路上最常见的翻车点。地理编码则是另一回事:它是一个数据依赖的问题,不是能靠推导解决的计算问题,用之前先想清楚数据从哪来、坐标系对不对、配额够不够。