📑 本页目录(点开跳转)
09 · log 域运算:logaddexp、logsumexp 与 log1p
⏱ 76 分钟 | ⭐ log(exp(a)+exp(b)) 里那两个 exp 一个都不用算 —— log 域是这一族的通解
🎯 一句话
只要你的量是「一串小概率连乘」或者「一串大指数相加」,就不要在原始域里算,换到 log 域。
换过去之后,连乘变成连加、exp 不用真的算出来,而 logaddexp 是这一族里最小的那个形式。
📌 softmax 减 max 的那道手撕题在 ML 基础 · 附录 C · 手撕代码速查 第 1 题,这里不重复。 那边给的是「白板上怎么写」,⭐ 这一章给的是它背后那条通用规则、以及 800 这个阈值是怎么来的。
🧩 一、logaddexp:这一族最小的形式
先看它在解决什么问题:
import numpy as np
import warnings
a, b = 800.0, 801.0
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
naive = np.log(np.exp(a) + np.exp(b))
print("naive log(exp(a)+exp(b)) =", naive, "|", w[0].message if w else "无警告")
print("np.logaddexp(a, b) =", np.logaddexp(a, b))
m = max(a, b)
print("闭式 max + log1p(exp(-|a-b|)) =", m + np.log1p(np.exp(-abs(a - b))))
print("两者逐位相同:", np.logaddexp(a, b) == m + np.log1p(np.exp(-abs(a - b))))
z = np.array([800., 801., 802.])
print()
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
print("naive log(sum(exp(z))) =", np.log(np.sum(np.exp(z))),
"|", w[0].message if w else "无警告")
mz = z.max()
print("mz + log(sum(exp(z-mz))) =", mz + np.log(np.sum(np.exp(z - mz))))
print("np.logaddexp.reduce(z) =", np.logaddexp.reduce(z))
对照
naive log(exp(a)+exp(b)) = inf | overflow encountered in exp
np.logaddexp(a, b) = 801.3132616875182
闭式 max + log1p(exp(-|a-b|)) = 801.3132616875182
两者逐位相同: True
naive log(sum(exp(z))) = inf | overflow encountered in exp
mz + log(sum(exp(z-mz))) = 802.4076059644444
np.logaddexp.reduce(z) = 802.4076059644444
⭐ logaddexp(a,b) 内部就是那个闭式,实测逐位相同:
log(eᵃ + eᵇ) = max(a,b) + log(1 + e^−|a−b|)
把 max 提出来之后,剩下的指数 −|a−b| 一定 ≤ 0,所以 exp 的结果一定在 (0, 1],永远不可能溢出。
两个 exp(800) 一个都没算,答案照样精确。
⭐ logsumexp 就是它的 n 元版本:np.logaddexp.reduce(z) 和手写的 m + log(sum(exp(z-m))) 给出完全相同的 802.4076059644444。NumPy 没有单独的 logsumexp 函数(scipy.special 里有),但 logaddexp.reduce 就是它,且不需要装 scipy。
⚠️ 注意 naive 那两行的输出还带了一条 RuntimeWarning: overflow encountered in exp ——这是浮点比整数强的地方:08 章那个整数回绕连这条警告都不给你。但警告默认只打到 stderr、跑批时通常被淹掉,所以第七节要讲怎么把它变成异常。
🧩 二、⭐ 为什么「log 域相加」是通解,不只是一个技巧
上面看到的是「防溢出」。它真正的价值是另一半:防下溢。
import numpy as np
rng = np.random.default_rng(0)
p = rng.uniform(1e-4, 1e-3, size=2000) # 2000 个小概率
print("直接连乘 np.prod(p) =", np.prod(p))
print("log 域相加 sum(log(p)) =", np.sum(np.log(p)))
print("换回来 exp(那个和) =", np.exp(np.sum(np.log(p))))
c = np.cumprod(p)
first_zero = int(np.argmax(c == 0.0)) if (c == 0.0).any() else -1
print("连乘到第", first_zero + 1, "个就变成 0 了")
print("这时 log 域的值还是", np.cumsum(np.log(p))[first_zero])
对照
直接连乘 np.prod(p) = 0.0
log 域相加 sum(log(p)) = -15314.752401524813
换回来 exp(那个和) = 0.0
连乘到第 99 个就变成 0 了
这时 log 域的值还是 -751.0149574628322
💀 只乘到第 99 个,答案就变成 0 了——而你要乘 2000 个。之后无论再乘什么,结果都是 0:你不是「精度差了点」,你是把整个量丢了。
⭐ log 域里,同一个量是 −15314.75,好端端地待在那儿。
这就是「通解」的意思:
| 原始域 | log 域 |
|---|---|
连乘 p1 * p2 * ... * pn |
连加 log p1 + log p2 + ... + log pn |
相加 e^a + e^b |
np.logaddexp(a, b) |
归一化 pi / sum(pj) |
log pi − logsumexp(log p) |
| 比大小 | ⭐ 直接比 log 值(log 单调递增,不用换回来) |
⚠️ 最后一行 exp(那个和) 还是 0.0,这不是 bug:真实答案 e^−15314 本来就远小于 float64 能表示的最小正数。⭐ 结论是:能不换回原始域就别换。argmax、排序、比较、加权(配合 logsumexp)全都可以在 log 域里做完。
🧩 三、800 这个数是从哪来的:真实的溢出阈值
import numpy as np
for t in (np.float64, np.float32, np.float16):
f = np.finfo(t)
print(f"{np.dtype(t).name:<8} max={float(f.max):<24} log(max)={np.log(float(f.max)):.2f} "
f"eps={float(f.eps):.3e} tiny={float(f.tiny):.3e}")
print()
print("exp(float32(88)) =", np.exp(np.float32(88)))
print("exp(float32(89)) =", np.exp(np.float32(89)))
print("exp(float64(709)) =", np.exp(np.float64(709)))
print("exp(float64(710)) =", np.exp(np.float64(710)))
print("exp(float16(11)) =", np.exp(np.float16(11)))
print("exp(float16(12)) =", np.exp(np.float16(12)))
print()
print("下溢那一头 exp(-745.0) =", np.exp(-745.0))
print(" exp(-746.0) =", np.exp(-746.0))
print()
print("np.exp(90) (float64) =", np.exp(90))
print("np.exp(np.float32(90)) =", np.exp(np.float32(90)))
| 浮点类型 | max | log(max) | eps | tiny |
|---|---|---|---|---|
| float64 | 1.7976931348623157e+308 | 709.78 | 2.220e-16 | 2.225e-308 |
| float32 | 3.4028234663852886e+38 | 88.72 | 1.192e-07 | 1.175e-38 |
| float16 | 65504.0 | 11.09 | 9.766e-04 | 6.104e-05 |
越过边界时的实测输出
- exp(float32(88)) = 1.6516363e+38
- exp(float32(89)) = inf
- exp(float64(709)) = 8.218407461554972e+307
- exp(float64(710)) = inf
- exp(float16(11)) = 5.987e+04
- exp(float16(12)) = inf
- 下溢那一头 exp(-745.0) = 5e-324
- exp(-746.0) = 0.0
⭐ exp 的临界指数就是 log(该 dtype 的最大值),一个 np.finfo 就查出来:
| dtype | max | 临界指数 log(max) |
实测最后一个不炸的整数 |
|---|---|---|---|
| float64 | 1.7976931348623157e+308 | 709.78 | 709 |
| float32 | 3.4028234663852886e+38 | 88.72 | 88 |
| float16 | 65504.0 | 11.09 | 11 |
⚠️⚠️ 这张表是本章最实用的一段,因为三个数差得极远:
np.exp(90) (float64) = 1.2204032943178408e+39
np.exp(np.float32(90)) = inf
同一个 90,float64 下是个有限的正常数字,float32 下直接 inf。
⭐ 所以「多大算大」这个问题没有统一答案,只有 per-dtype 的答案——你在 float64 里调通的代码,切到 float32 训练时可能当场出 nan,而你什么都没改。
⭐ float16 的 11.09 尤其值得记住:exp(12) 就炸了。半精度下 logits 稍微拉开一点就没法直接 exp——这也是为什么框架的 softmax / attention 内部会把 exp 那一步升到 float32 再算。该选哪个精度训练是 AI 基础设施 · 06 · 混合精度 的题;本章只负责告诉你每个精度的 exp 在哪一格炸。
下溢那一头:exp(-745.0) 给 5e-324(已经是次正规数,只剩 1 位有效数字),exp(-746.0) 直接是 0.0。⭐ 这解释了上一节「连乘到第 99 个就归零」:那时累积的 log 值是 −751.01,已经越过 −745 那道线了。
🛑 读到这里可以停 —— 前半章讲完了(约 26 分钟)。 后半章还有:
log1p/expm1:不是「全丢」,是悄悄丢了几位 · 一个真实现场:BCE 为什么要写成 log 域 ·np.errstate:让它报错,而不是静默出inf回来的时候不用重读,直接从下一节接着看就行。
🧩 四、log1p / expm1:不是「全丢」,是悄悄丢了几位
import numpy as np
for x in (1e-16, 1e-10, 1e-8, 1e-5):
naive, good = np.log(1 + x), np.log1p(x)
rel = abs(naive - good) / good if good != 0 else float("inf")
print(f"x={x:<8.0e} log(1+x)={naive:<24.17g} log1p(x)={good:<24.17g} 相对误差={rel:.2e}")
print()
for x in (1e-16, 1e-10, 1e-8, 1e-5):
naive, good = np.exp(x) - 1, np.expm1(x)
rel = abs(naive - good) / good if good != 0 else float("inf")
print(f"x={x:<8.0e} exp(x)-1={naive:<24.17g} expm1(x)={good:<24.17g} 相对误差={rel:.2e}")
print()
print("float64 的 eps =", np.finfo(np.float64).eps)
print("1 + 1e-16 == 1 吗:", 1 + 1e-16 == 1)
| x | log(1+x) | log1p(x) | 相对误差 |
|---|---|---|---|
| 1e-16 | 0 | 9.9999999999999998e-17 | 1.00e+00 |
| 1e-10 | 1.000000082690371e-10 | 9.9999999995000007e-11 | 8.27e-08 |
| 1e-08 | 9.9999998892252911e-09 | 9.999999950000001e-09 | 6.08e-09 |
| 1e-05 | 9.9999500003988414e-06 | 9.9999500003333302e-06 | 6.55e-12 |
| x | exp(x)-1 | expm1(x) | 相对误差 |
|---|---|---|---|
| 1e-16 | 0 | 9.9999999999999998e-17 | 1.00e+00 |
| 1e-10 | 1.000000082740371e-10 | 1.00000000005e-10 | 8.27e-08 |
| 1e-08 | 9.9999999392252903e-09 | 1.0000000049999999e-08 | 1.11e-08 |
| 1e-05 | 1.0000050000069649e-05 | 1.0000050000166668e-05 | 9.70e-12 |
float64 的 eps = 2.220446049250313e-16
1 + 1e-16 == 1 吗: True
成因只有一句:float64 的 eps 是 2.22e-16,意思是「1 附近能分辨的最小间隔」。
1 + 1e-16 就等于 1(实测 True),所以 log(1 + 1e-16) 只能是 log(1) = 0 ——那个 x 在做加法的那一瞬间就被扔掉了,log 是无辜的。
⭐ 真正难查的不是 x=1e-16 那一行,是 x=1e-10 那一行:
| x | 出了什么事 | 你会发现吗 |
|---|---|---|
| 1e-16 | 结果是 0,相对误差 100% | ⭐ 会。0 太扎眼了 |
| 1e-10 | 结果是 1.000000082690371e-10,相对误差 8.3e-8 | 💀 不会。它看起来完全正常,只是悄悄丢了 7 位有效数字 |
| 1e-5 | 相对误差 6.6e-12 | 无所谓 |
💀 一个「看起来对、其实只剩 8 位有效数字」的中间量,会一路往下游传,最后表现成「模型指标差了 0.3%,查不出原因」。
⭐ 判据很简单:只要你在算 log(1 + 很小的东西) 或者 exp(很小的东西) - 1,就换成 log1p / expm1。不用先判断它多小,log1p(5.0) 和 log(6.0) 也是一样的答案,换过去没有代价。
⚠️ log1p 在站内出现过 11 次,全部是特征工程的长尾变换(把 amount 压平)。那个用途是对的,⭐ 但它顺带还有本节这个数值用途,站里从没提过。两个用途没有冲突——np.log1p(df["amount"]) 同时享受了两者。
🧩 五、一个真实现场:BCE 为什么要写成 log 域
站内 ML 基础 · 18 · 挑战项目 A · 手搓 mini-torch 有一条要求是「BCE 要用 logsumexp 形式」。这一节把那句话展开。
import numpy as np
import warnings
x = np.array([-800., -50., 0., 50., 800.])
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
naive = np.log(1 + np.exp(x)) # softplus 的教科书写法
print("naive log(1+exp(x)) =", naive, "|", w[0].message if w else "无警告")
print("np.logaddexp(0, x) =", np.logaddexp(0, x)) # 同一个东西,不会炸
def bce_naive(z, y):
p = 1 / (1 + np.exp(-z))
return -(y * np.log(p) + (1 - y) * np.log(1 - p))
def bce_stable(z, y):
return np.logaddexp(0, -z) + (1 - y) * z # = log(1+exp(-z)) + (1-y)z
z = np.array([-800., -30., 0., 30., 800.])
y = np.array([1., 1., 1., 0., 0.])
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
print()
print("naive BCE =", bce_naive(z, y), "| 警告", [str(i.message) for i in w])
print("stable BCE =", bce_stable(z, y))
对照
naive log(1+exp(x)) = [ 0. 0. 0.69314718 50. inf] | overflow encountered in exp
np.logaddexp(0, x) = [0.00000000e+00 1.92874985e-22 6.93147181e-01 5.00000000e+01
8.00000000e+02]
naive BCE = [ inf 30. 0.69314718 30.00102056 inf] | 警告 ['overflow encountered in exp', 'divide by zero encountered in log', 'divide by zero encountered in log']
stable BCE = [8.00000000e+02 3.00000000e+01 6.93147181e-01 3.00000000e+01
8.00000000e+02]
三处出事,严重程度递增:
| 位置 | naive | stable | 说明 |
|---|---|---|---|
z=±800 |
inf |
800.0 | ⭐ 会炸掉整个 batch 的 loss |
x=-50 那一格 |
0.0 | 1.93e-22 | 小量被吃掉——log(1+exp(-50)) 里的 1+ 把它抹平了 |
💀 z=30, y=0 |
30.00102056 | 30.0 | 💀 既没报错也没出 inf,就是错的 |
💀 最后一行才是这一节的价值。p = 1/(1+exp(-30)) 约等于 1 − 9.36e-14,接着算 1 - p 时前 13 位全被抵消掉——这是上一节 log1p 那个坑的同一种病,只是换了个身份出现。它不报警告、不出 inf、数量级也对,只是从第 4 位小数开始就是错的。
⭐ np.logaddexp(0, x) 就是 softplus 的正确写法,也是 log(1+exp(x)) 这个式子在 NumPy 里的标准答案。记住这一个搭配,比记住整个推导有用。
🛑 读到这里可以停 —— 已经读了约 46 分钟。 最后一段还有(约 27 分钟):
np.errstate:让它报错,而不是静默出inf· 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🧩 六、np.errstate:让它报错,而不是静默出 inf
import numpy as np
print("默认行为 exp(800) =", np.exp(np.array([800.0])))
for kind, expr in [
("over", lambda: np.exp(np.array([800.0]))),
("invalid", lambda: np.array([np.inf]) - np.inf),
("divide", lambda: np.array([1.0]) / np.array([0.0])),
("under", lambda: np.exp(np.array([-800.0]))),
]:
try:
with np.errstate(**{kind: "raise"}):
r = expr()
print(f"{kind:<8} 没抓到,结果 = {r}")
except FloatingPointError as e:
print(f"{kind:<8} FloatingPointError: {e}")
print()
print("nan 的检测 np.isnan([nan, 1.0]) =", np.isnan(np.array([np.nan, 1.0])))
print("inf 的检测 np.isfinite([inf, 1.0]) =", np.isfinite(np.array([np.inf, 1.0])))
print("nan == nan ->", np.nan == np.nan, " 所以只能用 isnan")
对照
默认行为 exp(800) = [inf]
over FloatingPointError: overflow encountered in exp
invalid FloatingPointError: invalid value encountered in subtract
divide FloatingPointError: divide by zero encountered in divide
under FloatingPointError: underflow encountered in exp
因果链
四种浮点错误,errstate 全都能变成异常:
| 种类 | 什么时候触发 | 该不该 raise |
|---|---|---|
over |
结果太大 → inf |
⭐ 该。几乎一定是 bug |
invalid |
inf - inf、0/0、sqrt(-1) → nan |
⭐ 最该。nan 会污染下游所有计算 |
divide |
除以 0 → inf,log(0) → -inf |
⭐ 该,除非你确实想要 -inf(log 域里 -inf 表示概率 0,是合法的) |
under |
结果太小 → 0 | ⚠️ 通常不该。归一化之后大量小数下溢是正常的,开了会满地噪声 |
⭐ 推荐用法:不要全局 np.seterr,用 with np.errstate(...) 只包住你怀疑的那几行:
import numpy as np
def normalize(x):
with np.errstate(divide="raise", invalid="raise"): # 只管这一段
return x / x.sum()
print(normalize(np.array([1., 2., 3.])))
try:
print(normalize(np.array([0., 0., 0.]))) # 0/0
except FloatingPointError as e:
print("提前抓住了:", e)
print("with 之外:", np.array([0., 0., 0.]) / 0.0) # 同一件事,又变回静默
要点
[0.16666667 0.33333333 0.5 ]
提前抓住了: invalid value encountered in divide
with 之外: [nan nan nan]
⚠️ 最后一行是这段的重点:errstate 的作用域严格限于 with 块内,出了块同一件事又静默出 nan。所以它是「在可疑的地方装一个报警器」,不是「全局保险」。
💀💀 一条必须记住的边界:np.errstate 对整数回绕完全无效。08 章实测过:with np.errstate(all="raise") 包着 int32 最大值加 1,照样静默给出 −2147483648。原因是 errstate 控制的是 IEEE 754 的浮点异常旗标,整数加法根本不走那条路。别把这一节的安全感带到整数那边去。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| ML 基础 · 附录 C · 手撕代码速查 | ⭐ 第 1 题「Softmax + 交叉熵」是白板上怎么写,那是面试的得分点,本章不重复。⭐ 两边是「动作」和「阈值」的关系:那里说「减 max 防溢出」,这里告诉你 float64 是 709.78、float32 是 88.72、float16 是 11.09 |
| ML 基础 · 18 · 挑战项目 A · 手搓 mini-torch | 那里有两处把「用 logsumexp 技巧」「BCE 要用 logsumexp 形式」当命令给出。⭐ 本章第五节就是把那两句话展开——包括 z=30, y=0 那个既不报错也不出 inf、只是从第 4 位小数开始错的例子 |
| 08 · 整数 dtype 的真相 | 💀 同一个 np.errstate,在那边一点用都没有。整数回绕不走浮点异常旗标,all="raise" 也抓不住。两章一起读才知道「安全感」的边界在哪 |
| AI 基础设施 · 06 · 混合精度 | 该选哪个精度训练、loss scaling 怎么配,是那一章的题(工程选型)。本章只给数值行为:每个 dtype 的 exp 在哪一格炸。⭐ float16 的 11.09 正是那边要 loss scaling 的原因之一 |
| 数学原理 · 05c · SVD 与实践 | ⚠️ 那里也讲「数值稳定」,但说的是线性代数意义(条件数、正规方程为什么把 κ 平方掉)。本章只讲浮点表示 + exp/log 一族。两边不重叠,撞上「数值不稳定」这个词时先分清是哪一种 |
| ML 基础 · 16 · 特征工程基础 | 那里用 np.log1p 做长尾变换。⭐ 本章第四节说的是同一个函数的另一个用途(小量的有效数字),两个用途不冲突,一行代码同时享受 |
✅ 检查点
np.logaddexp(800, 801)内部算的是哪个闭式?为什么那个式子永远不会溢出?- NumPy 里有
logsumexp这个函数吗?不装 scipy 的话该写什么? - 2000 个 1e-4 到 1e-3 之间的概率连乘,
np.prod给什么?乘到第几个就出问题了?log 域给的是多少? - float64 / float32 / float16 的
exp临界指数各是多少?这三个数怎么查出来的? np.exp(90)是inf吗?log(1 + 1e-16)为什么是 0?这是log的问题吗?log(1+x)在 x=1e-16 和 x=1e-10 两种情况下,哪一种更危险?为什么?log(1 + exp(x))的正确写法是什么?- naive BCE 在
z=30, y=0上给出 30.00102056 而正确答案是 30.0 —— 它报错了吗?成因是什么? np.errstate的四种错误里,哪一种通常不该开 raise?为什么?np.errstate(all="raise")能不能兜住整数溢出?
👀 答案
- max(a,b) + log(1 + exp(−|a−b|)),实测和
np.logaddexp逐位相同(都是 801.3132616875182)。永不溢出是因为把 max 提出来之后,剩下的指数−|a−b|一定 ≤ 0,exp的结果落在 (0, 1]。两个exp(800)一个都没算。 - 没有(
scipy.special里才有)。不装 scipy 就写np.logaddexp.reduce(z)——实测和手写的m + log(sum(exp(z-m)))给出完全相同的 802.4076059644444。 np.prod给 0.0。💀 乘到第 99 个就变成 0 了(那时累积 log 值是 −751.01,已越过 −745 那道下溢线)。log 域给 −15314.752401524813,好端端地待着。- 709.78 / 88.72 / 11.09。用
np.log(np.finfo(dtype).max)查——exp的临界指数就是「该 dtype 最大值的对数」。实测最后一个不炸的整数分别是 709 / 88 / 11。 - float64 下不是:
np.exp(90) = 1.2204032943178408e+39,一个有限的正常数字。⚠️ 但np.exp(np.float32(90))是inf。「多大算大」没有统一答案,只有 per-dtype 的答案。 - 因为 float64 的
eps是 2.22e-16,1 + 1e-16就等于 1(实测True)。不是log的问题——那个 x 在做加法的那一瞬间就被扔掉了。 - x=1e-10 更危险。1e-16 那种结果直接是 0,相对误差 100%,太扎眼一定会被发现;1e-10 给的是 1.000000082690371e-10,看起来完全正常,只是悄悄丢了 7 位有效数字(相对误差 8.3e-8),会一路传到下游变成「指标差 0.3% 查不出原因」。
np.logaddexp(0, x)。它就是 softplus 的标准写法。- ⚠️ 没报错,也没出
inf,数量级还是对的。成因:p = 1/(1+exp(-30))约等于1 − 9.36e-14,算1 - p时前 13 位全被抵消——和第四节log1p那个坑是同一种病,只是换了身份。正确写法np.logaddexp(0, -z) + (1-y)*z给 30.0。 under(下溢)通常不该开。归一化之后大量小数下溢是正常现象,开了满地噪声。最该开的是invalid——nan会污染下游所有计算。- 不能。 08 章实测:
with np.errstate(all="raise")里int32最大值 +1 照样静默给出 −2147483648。errstate控制的是 IEEE 754 浮点异常旗标,整数加法不走那条路。
🛑 可以停在这里
⚡ 走神救援
⭐ 核心恒等式:
log(e^a + e^b) = max(a,b) + log(1 + e^−|a−b|)。 把 max 提出来之后,剩下的指数一定 ≤ 0,exp的结果落在 (0,1],永不溢出。NumPy 里它就是
np.logaddexp,n 元版是np.logaddexp.reduce——⭐ 没有单独的logsumexp,但这个就是它,不用装 scipy。⭐ 它真正的价值在防下溢那一半:几千个小概率连乘,
np.prod直接给 0.0——而且乘到第一百个左右就已经归零了;log 域里同一个量是个正常的负数。所以连乘换连加、归一化换成减 logsumexp,⭐ 能不换回原始域就别换(比大小直接比 log 值)。⚠️⚠️ 「多大算大」只有 per-dtype 的答案:同一个指数 90,float64 下是个有限的数,float32 下就是
inf。溢出阈值大致是 float64 约 709、float32 约 88、float16 约 11。
log1p/expm1:1 + 1e-16 == 1为真,所以log(1+1e-16)只能是 0——⭐ 不是 log 的错,是加法先把它扔了。💀 但真正危险的不是那个明显归零的,而是中间那一档:结果看起来完全正常,却悄悄丢了七位有效数字,然后一路传到下游。⭐ 判据很简单:只要在算
log(1+很小)或exp(很小)-1,就换log1p/expm1。
下一节 👉 10-随机数种子与可复现.md