Skip to content

第 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),该退役了。

python
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 + 35求和
-减2 - 3-1差值
*乘2 * 36单位换算(Ah→mAh)
/真除5 / 22.5结果一定是 float
//整除5 // 22向下取整,余数丢掉
%取余5 % 21判断奇偶、循环编号
**幂2 ** 38退化公式里的 c ** beta
点开:/ 和 // 最容易搞混,看三个例子
python
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,而后面所有基于它的计算全废。

记忆法:

  • / 一个斜杠 → "真除法",跟数学一样;
  • // 两个斜杠 → "砍掉小数",像地板一样往下砸。

% 的实用场景(后面会用到):

python
for cycle in range(1, 11):
    if cycle % 2 == 0:              # 偶数圈
        print(f"第 {cycle} 圈是偶数圈")

4.3 运算符优先级:拿不准就加括号 ​

和数学一样,Python 有优先级:** > * / // % > + -。

python
print(1 + 2 * 3)          # 7    先算乘法
print((1 + 2) * 3)        # 9    括号优先

但真实 bug 往往出在"我以为优先级是这样"的地方:

python
soh = 0.92

print(1 - soh * 100)      # -91.0        ❌ 先算了 soh * 100
print((1 - soh) * 100)    # 8.0          ✅ 这才是衰减百分比

-91.0 是一个负的百分比 —— 明显不合理,但代码不报错。

纪律:涉及"先减后乘"或"先加后除"时,一律加括号。 括号不花钱,加错了也能一眼看出来;而优先级算错的 bug,可能要等到报告被人质疑时才发现。

4.4 计算 SOH 和衰减率 ​

python
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 返回新值,不改原变量。

python
y = 3.14159
round(y, 2)              # 算了一个新数 3.14,然后……扔掉了
print(y)                 # 3.14159  ← y 完全没变

z = round(y, 2)          # ✅ 要用返回值接收
print(z)                 # 3.14

这和列表的 .append() 是同一类坑(第 06 章):不接收返回值 = 白算。

坑二:round 不总是"四舍五入"。

python
print(round(2.675, 2))   # 2.67  ← 不是 2.68!

为什么:2.675 在二进制浮点里根本存不下,实际存的是 2.67499999999999982…, 所以按"四舍五入"规则应该舍去。

这就是 4.5 节要讲的浮点误差。结论:round 只适合"显示用", 不要用它做需要精确的金融计算。

4.5 浮点误差:0.1 + 0.2 不等于 0.3 ​

这是每个程序员都会遇到一次的现象:

python
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 都一样。这是二进制浮点的固有特性。

三条实用结论:

① 永远不要用 == 比较浮点数。

python
# ❌
if soh == 0.92:
    print("相等")

# ✅ 用"足够接近"来判断
if abs(soh - 0.92) < 1e-9:
    print("相等")

1e-9 是 0.000000001 的科学计数法写法。这个"容忍度"在数值计算里叫 epsilon。

② 需要精确小数(钱、账)时,用 decimal。

python
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))      # 0.3   ← 精确

③ 科学计算里,浮点误差可以接受。

电池容量、温度、电压本身就有 ±0.5% 的测量噪声,1e-16 级别的浮点误差毫无影响。 只有在"比较"和"累计大量数"的时候才需要小心。

4.6 显示时格式化,别用 round 改数值 ​

更专业的做法:保留原始精度,只在打印时控制显示。

python
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 自检 ​

python
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 完整示例与输出 ​

bash
cd code
python demos/ch04-numeric.py

真实输出:

text
=== ① `/` 与 `//` ===
真除 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%
参考答案
python
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. 优先级坑:1 - soh * 100 会先算乘法,得到 -91.0。必须写 (1 - soh) * 100。
  2. int 1 与 float soh 相减,结果自动是 float,不用手动转类型。
  3. 别用 == 比较浮点数。要比就用 abs(a - b) < 1e-9。

完整练习:exercises/ex04-04-numeric

练习 2:为什么这两个表达式结果不同?

不要运行,先预测:

python
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 再拼 % 更不容易错:

python
print(f"衰减率 = {(1 - soh):.1%}")     # 衰减率 = 8.0%   ← 自动乘 100
练习 3:找出这段代码的三处问题
python
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")
参考答案

问题 ①:用了 // 而不是 /

python
soh = cap_now // cap_rated     # 0.0  ← 把 0.92 砍成了 0

应该用 /。// 是整除,会丢掉小数部分,而且不报错。

问题 ②:优先级错了

python
fade = round(1 - soh * 100, 1)     # 先算 soh*100,得到 -91.0

应该是 round((1 - soh) * 100, 1)。括号不能省。

问题 ③:用 == 比较浮点数

python
if soh == 0.92:     # 0.9199999999999999 != 0.92,条件永远为 False

应该写:

python
if abs(soh - 0.92) < 1e-9:
    print("SOH 约等于 0.92")

修正后的完整代码:

python
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。

python
import numpy as np
loss = -np.log(0.0)          # inf  ——  灾难

所以交叉熵实现里都会加一个极小的 epsilon:

python
loss = -np.log(y_pred + 1e-7)     # 防止 log(0)

③ 比较浮点结果来判断"训练收敛"。

python
# ❌ 永远不会触发(loss 是浮点,几乎不可能精确相等)
if loss == prev_loss:
    break

# ✅ 用相对变化
if abs(loss - prev_loss) < 1e-6:
    break

④ np.isclose 和 np.allclose。 NumPy 提供了现成的"浮点比较"工具,比手写 abs(a-b) < 1e-9 更周全:

python
import numpy as np
print(np.allclose([0.1 + 0.2], [0.3]))     # True  ← 自动处理相对/绝对容忍度

本书示例里凡是要"验证两种算法结果一致",用的都是 np.allclose(第 20 章)。

一句话总结:浮点误差不是"小问题",它是数值计算领域的一整门学问。 作为使用者,你只需要记住两条:别用 == 比浮点,别在中间步骤做舍入。


下一章:第 05 章 · 字符串与格式化输出