第 04 章 · 数值与算术:算一支电芯的 SOH
这一章要解决什么:用算术算出 SOH(健康度)和容量衰减率,并打印成人类能读的形式。 顺带打掉三个"看起来会算,其实算错了"的坑:
/和//、运算符优先级、浮点误差。
本章你需要完成的代码:demos/ch04-numeric.py
4.1 问题:SOH 是什么
SOH(State of Health,健康度) 是电池领域最核心的指标,定义非常简单:
SOH = 当前实测容量 / 额定容量一支新电芯 SOH ≈ 1.00(100%);行业惯例把 SOH < 0.80 视为寿命终止(EOL,End of Life),该退役了。
cap_rated = 2.5 # 额定容量(Ah)
cap_now = 2.30 # 当前实测容量(Ah)
soh = cap_now / cap_rated
print(soh) # 0.9199999999999999 ← 注意这个"尾巴"0.9199999999999999 就是第一个坑,我们在 4.5 节讲。
4.2 七种算术运算符
| 运算符 | 名称 | 例子 | 结果 | 用在哪 |
|---|---|---|---|---|
+ | 加 | 2 + 3 | 5 | 求和 |
- | 减 | 2 - 3 | -1 | 差值 |
* | 乘 | 2 * 3 | 6 | 单位换算(Ah→mAh) |
/ | 真除 | 5 / 2 | 2.5 | 结果一定是 float |
// | 整除 | 5 // 2 | 2 | 向下取整,余数丢掉 |
% | 取余 | 5 % 2 | 1 | 判断奇偶、循环编号 |
** | 幂 | 2 ** 3 | 8 | 退化公式里的 c ** beta |
点开:/ 和 // 最容易搞混,看三个例子
print(2.30 / 2.5) # 0.92 真除 —— 结果保留小数
print(2.30 // 2.5) # 0.0 整除 —— 向下取整(因为 2.30/2.5 = 0.92,取整就是 0)
print(2.30 % 2.5) # 2.30 取余 —— 0.92 除以 1 余 0.92,整数商是 0,余数是 2.30第二个输出 0.0 是最危险的:它不报错,只是悄悄丢掉了小数部分。 如果你本意是算 SOH,用了 // 就会得到 0.0,而后面所有基于它的计算全废。
记忆法:
/一个斜杠 → "真除法",跟数学一样;//两个斜杠 → "砍掉小数",像地板一样往下砸。
% 的实用场景(后面会用到):
for cycle in range(1, 11):
if cycle % 2 == 0: # 偶数圈
print(f"第 {cycle} 圈是偶数圈")4.3 运算符优先级:拿不准就加括号
和数学一样,Python 有优先级:** > * / // % > + -。
print(1 + 2 * 3) # 7 先算乘法
print((1 + 2) * 3) # 9 括号优先但真实 bug 往往出在"我以为优先级是这样"的地方:
soh = 0.92
print(1 - soh * 100) # -91.0 ❌ 先算了 soh * 100
print((1 - soh) * 100) # 8.0 ✅ 这才是衰减百分比-91.0 是一个负的百分比 —— 明显不合理,但代码不报错。
纪律:涉及"先减后乘"或"先加后除"时,一律加括号。 括号不花钱,加错了也能一眼看出来;而优先级算错的 bug,可能要等到报告被人质疑时才发现。
4.4 计算 SOH 和衰减率
cap_rated = 2.5
cap_now = 2.30
soh = cap_now / cap_rated
print(f"SOH = {round(soh, 2)}") # SOH = 0.92
fade_percent = round((1 - soh) * 100, 1)
print(f"容量衰减率 = {fade_percent}%") # 容量衰减率 = 8.0%round(x, n) 把 x 四舍五入到 n 位小数。
点开:round 的两个坑
坑一:round 返回新值,不改原变量。
y = 3.14159
round(y, 2) # 算了一个新数 3.14,然后……扔掉了
print(y) # 3.14159 ← y 完全没变
z = round(y, 2) # ✅ 要用返回值接收
print(z) # 3.14这和列表的 .append() 是同一类坑(第 06 章):不接收返回值 = 白算。
坑二:round 不总是"四舍五入"。
print(round(2.675, 2)) # 2.67 ← 不是 2.68!为什么:2.675 在二进制浮点里根本存不下,实际存的是 2.67499999999999982…, 所以按"四舍五入"规则应该舍去。
这就是 4.5 节要讲的浮点误差。结论:round 只适合"显示用", 不要用它做需要精确的金融计算。
4.5 浮点误差:0.1 + 0.2 不等于 0.3
这是每个程序员都会遇到一次的现象:
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False ← !?点开:为什么会这样?(理解了就不用死记)
根本原因:十进制小数无法用二进制精确表示。
用十进制类比:1/3 = 0.3333... 永远写不完,只能写近似值。 二进制也有写不完的数 —— 比如 0.1:
0.1(十进制)= 0.000110011001100110011...(二进制,无限循环)计算机的 float 是 64 位定长(IEEE 754 双精度),只能截断存储。 所以存进去的 0.1 已经是"近似值",运算后误差被放大,就成了 0.30000000000000004。
这不是 Python 的 bug,C、Java、JavaScript 都一样。这是二进制浮点的固有特性。
三条实用结论:
① 永远不要用 == 比较浮点数。
# ❌
if soh == 0.92:
print("相等")
# ✅ 用"足够接近"来判断
if abs(soh - 0.92) < 1e-9:
print("相等")1e-9 是 0.000000001 的科学计数法写法。这个"容忍度"在数值计算里叫 epsilon。
② 需要精确小数(钱、账)时,用 decimal。
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3 ← 精确③ 科学计算里,浮点误差可以接受。
电池容量、温度、电压本身就有 ±0.5% 的测量噪声,1e-16 级别的浮点误差毫无影响。 只有在"比较"和"累计大量数"的时候才需要小心。
4.6 显示时格式化,别用 round 改数值
更专业的做法:保留原始精度,只在打印时控制显示。
soh = 2.30 / 2.5
print(f"保留 2 位小数:{soh:.2f}") # 保留 2 位小数:0.92
print(f"百分比、1 位:{soh:.1%}") # 百分比、1 位:92.0%
print(f"千分位:{1234567:,}") # 千分位:1,234,567
print(f"科学计数:{0.00012345:.2e}") # 科学计数:1.23e-04为什么这样更好:
| 做法 | 数值精度 | 显示 | 评价 |
|---|---|---|---|
round(x, 2) | 被永久改成 2 位 | 干净 | 中间步骤舍入会累积误差 |
f"{x:.2f}" | 保持完整精度 | 干净 | ✅ 推荐 |
核心原则:中间计算保持满精度,只在最后输出时舍入。
想象你要算 1000 个数的平均值:如果每个数先 round(x, 2) 再相加, 累积误差可能到 0.5 以上;而保持原值到最后再舍入,误差可以忽略。
点开:f-string 格式化迷你语言速查
格式符的结构是固定的:
{ 值 : [填充字符][对齐符][宽度][.精度][类型] }| 写法 | 输出 | 说明 |
|---|---|---|
{x:.2f} | 0.50 | 两位小数 |
{x:.1%} | 50.0% | 百分比(自动乘 100 加 %) |
{x:>8} | ab | 右对齐,总宽 8 |
{x:<8} | ab | 左对齐 |
{x:^8} | ab | 居中 |
{x:08.2f} | 00003.14 | 补零到 8 位 |
{x:,} | 1,234,567 | 千分位 |
{x:e} | 1.234500e+03 | 科学计数法 |
{x:+.2f} | +3.14 / -3.14 | 强制显示正负号 |
顺序不能乱:写成 {x:.3f>8} 会报错。
常用的输入输出类型:
| 类型符 | 含义 | 例子 |
|---|---|---|
f | 定点小数 | 0.50 |
e | 科学计数 | 5.00e-01 |
% | 百分比 | 50.0% |
d | 十进制整数 | 42 |
, | 千分位(与 f 连用) | 1,234.57 |
第 37 章画热力图时,格子里写的就是 f"{v:.2f}" —— 这就是它的实战用法。
4.7 用 assert 自检
soh = 2.30 / 2.5
assert abs(soh - 0.92) < 1e-9, "SOH 算错了"
print("assert 通过:soh == 0.92 ✅")assert 条件, 消息:条件为 False 时抛出 AssertionError(带消息),程序立即停止。
为什么值得写:上面那个 abs(...) < 1e-9 就是"浮点比较"的正确写法。 把它写成 assert,等于给代码加了一条"我确信这里一定成立"的声明 ——
- 如果你改代码改错了,它会马上告诉你哪一步开始不对;
- 而不是等跑到最后,看到一个诡异的数字,再回头一点点排查。
⚠ 一个限制:用 python -O 运行会关闭所有 assert。 所以"必须执行的校验"(比如"这条数据不能为空")要用 raise(第 13 章),不能用 assert。
4.8 完整示例与输出
cd code
python demos/ch04-numeric.py真实输出:
=== ① `/` 与 `//` ===
真除 2.30 / 2.5 = 0.9199999999999999
整除 2.30 // 2.5 = 0.0 ← 向下取整,丢了小数
取余 2.30 % 2.5 = 2.30 ← 剩下的零头
幂 2 ** 3 = 8
=== ② SOH 与容量衰减率 ===
SOH = 0.92
容量衰减率 = 8.0%
=== ③ 优先级:拿不准就加括号 ===
1 + 2 * 3 = 7
(1 + 2) * 3 = 9
1 - soh * 100 = -91.0 ← 先算乘法,结果不是百分比!
(1 - soh) * 100 = 8.0 ← 这才对
=== ④ 浮点误差(一定要知道) ===
0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 == 0.3 ? False ← 永远别用 == 比 float!
abs(...) < 1e-9 ? True ← 正确比法
=== ⑤ round 的坑 ===
round(2.675, 2) = 2.67 ← 不是 2.68!浮点表示本身就不精确
round 返回新值,不改原变量:
round 后 z = 3.14,但 y 还是 3.14159 ← 忘了接收返回值 = 白算本章练习
练习 1:用算术算容量衰减率
已知 cap_rated = 2.5、cap_now = 2.30,算出 SOH 并打印;再把容量衰减率以百分比形式打印(保留 1 位小数)。
期望输出:
SOH = 0.92
容量衰减率 = 8.0%参考答案
cap_rated = 2.5
cap_now = 2.30
# ① 真除法 `/` 的结果是 float
soh = cap_now / cap_rated
print(f"SOH = {round(soh, 2)}")
# ② 衰减率:先算 (1 - soh) 再乘 100 —— 括号不能省
fade_percent = round((1 - soh) * 100, 1)
print(f"容量衰减率 = {fade_percent}%")
# 等价的一行写法(不用中间变量):
# print(f"容量衰减率 = {round((1 - cap_now / cap_rated) * 100, 1)}%")
# 顺手验证
print(f"(校验)1 - soh = {1 - soh:.4f},乘 100 = {(1 - soh) * 100:.4f}")三个要点:
- 优先级坑:
1 - soh * 100会先算乘法,得到-91.0。必须写(1 - soh) * 100。 int 1与float soh相减,结果自动是 float,不用手动转类型。- 别用
==比较浮点数。要比就用abs(a - b) < 1e-9。
完整练习:exercises/ex04-04-numeric
练习 2:为什么这两个表达式结果不同?
不要运行,先预测:
soh = 0.92
print(1 - soh * 100)
print((1 - soh) * 100)然后再运行验证。如果你的预测错了,说出优先级规则。
参考答案
-91.0
8.0运算符优先级(从高到低):
** ← 最高
* / // % ← 乘除类
+ - ← 加减类
== != < > <= >= ← 比较
not and or ← 逻辑第一个表达式 1 - soh * 100: * 优先级高于 -,所以先算 0.92 * 100 = 92.0,再算 1 - 92.0 = -91.0。
第二个表达式 (1 - soh) * 100: 括号强制先算 1 - 0.92 = 0.08,再算 0.08 * 100 = 8.0。
-91.0 就是那个"静默 bug":程序不报错,但结果毫无意义。
实践纪律:
- 涉及"先减后乘"或"先加后除",一律加括号;
- 觉得"这里可能有问题"就加括号 —— 括号的成本是 2 个字符,优先级 bug 的成本是几小时。
顺带一个技巧:用 %.1f%% 或 {x:.1%} 输出百分比,比手动乘 100 再拼 % 更不容易错:
print(f"衰减率 = {(1 - soh):.1%}") # 衰减率 = 8.0% ← 自动乘 100练习 3:找出这段代码的三处问题
cap_rated = 2.5
cap_now = 2.30
soh = cap_now // cap_rated
print(f"SOH = {soh}")
fade = round(1 - soh * 100, 1)
print(f"衰减率 = {fade}%")
if soh == 0.92:
print("SOH 正好是 0.92")参考答案
问题 ①:用了 // 而不是 /
soh = cap_now // cap_rated # 0.0 ← 把 0.92 砍成了 0应该用 /。// 是整除,会丢掉小数部分,而且不报错。
问题 ②:优先级错了
fade = round(1 - soh * 100, 1) # 先算 soh*100,得到 -91.0应该是 round((1 - soh) * 100, 1)。括号不能省。
问题 ③:用 == 比较浮点数
if soh == 0.92: # 0.9199999999999999 != 0.92,条件永远为 False应该写:
if abs(soh - 0.92) < 1e-9:
print("SOH 约等于 0.92")修正后的完整代码:
cap_rated = 2.5
cap_now = 2.30
soh = cap_now / cap_rated # ① 用真除法
print(f"SOH = {soh:.2f}") # 显示时格式化,不 round
fade = (1 - soh) * 100 # ② 加括号
print(f"衰减率 = {fade:.1f}%")
if abs(soh - 0.92) < 1e-9: # ③ epsilon 比较
print("SOH 约等于 0.92")这三个问题的共同点:都不报错。
| 问题 | 报错? | 后果 |
|---|---|---|
// 而不是 / | ❌ 不报错 | SOH 变成 0.0,后面全废 |
| 优先级错误 | ❌ 不报错 | 衰减率变成 -91.0%,明显荒谬但代码"跑通了" |
== 比较浮点 | ❌ 不报错 | 条件永远为 False,某个分支悄悄从不执行 |
这就是为什么"跑通了"不等于"算对了"。 本书后面会反复强调的一件事:用 assert 把"我期望的结果"写进代码。
面向 AI 开发:浮点误差在模型训练里会放大
你可能觉得"1e-16 的误差无所谓"。在单个算术里确实无所谓,但在 AI 里它会被放大:
① 梯度累加。 训练一个模型要跑几十万步,每步都在累加梯度(或动量)。 float32 只有约 7 位有效数字,累加几十万次后,小梯度会被"吃掉"(下溢)。
这就是为什么会有这些工程实践:
| 技术 | 解决什么 |
|---|---|
float64 累加 | 数值稳定性优先于速度时的默认选择 |
| 梯度缩放(GradScaler) | 混合精度训练时防止梯度下溢 |
| Kahan 求和 | 大数组求和时补偿误差 |
② 损失函数里的 log。
import numpy as np
loss = -np.log(0.0) # inf —— 灾难所以交叉熵实现里都会加一个极小的 epsilon:
loss = -np.log(y_pred + 1e-7) # 防止 log(0)③ 比较浮点结果来判断"训练收敛"。
# ❌ 永远不会触发(loss 是浮点,几乎不可能精确相等)
if loss == prev_loss:
break
# ✅ 用相对变化
if abs(loss - prev_loss) < 1e-6:
break④ np.isclose 和 np.allclose。 NumPy 提供了现成的"浮点比较"工具,比手写 abs(a-b) < 1e-9 更周全:
import numpy as np
print(np.allclose([0.1 + 0.2], [0.3])) # True ← 自动处理相对/绝对容忍度本书示例里凡是要"验证两种算法结果一致",用的都是 np.allclose(第 20 章)。
一句话总结:浮点误差不是"小问题",它是数值计算领域的一整门学问。 作为使用者,你只需要记住两条:别用 == 比浮点,别在中间步骤做舍入。