lnx等于多少:自然对数的手算、浮点实现与精度避坑
“lnx 等于多少、怎么用计算机算出来”,这个问题我一年能被问上几十遍。问的人大致分三类:手边没计算器、做题卡在最后一步的;写代码时调了 log 却发现结果和教材对不上的;还有极少数是真的好奇,按下那个 ln 键之后机器里到底跑了一套什么逻辑。三类人的需求差得挺远——第一类要“快”,第二类要“准”,第三类要“明白”。自然对数的定义一句话就说完,e 的多少次方等于 x,可真要落到具体数字上,计算机并不会“解方程”,它只会加减乘除,剩下的全靠数学变换硬凑出来。
这篇就按这三条路分别走一遍。先讲清楚 lnx 到底是个什么东西、为什么不同工具给出的答案看起来不一样;再拆手工计算方法,包括泰勒级数为什么会“坑人”、区间压缩怎么救场;然后钻进浮点数的内部,看一次 log 调用背后发生了什么;最后落到实操,Python、C、Excel、科学计算器分别怎么按,以及想自己写一个 log 函数该从哪下手。中间会穿插我自己踩过的坑,比如把 log 当成以 10 为底、把小量直接扔进 log 导致精度全丢这类,都是真实发生过的低级错误。
1. 先把“lnx 等于多少”这个问题拆开
1.1 自然对数的三种等价定义,选哪个决定了你怎么算
自然对数 ln x 最常见的定义是“e 的多少次方等于 x”,写成 ln x = y 等价于 e^y = x,其中 e ≈ 2.718281828459045。这个定义好理解,但对计算毫无帮助,因为它是隐式的,你得反过来解方程才能得到 y。第二种定义是积分形式,ln x = ∫(1 到 x) dt/t,也就是曲线 y = 1/t 在区间 [1, x] 下方的面积。这个定义解释了很多性质,比如为什么 ln 1 = 0(区间长度为零),为什么 x < 1 时结果是负数(积分方向反过来),也解释了为什么 ln 的增长越来越慢——曲线越往右越低。
第三种是级数定义,ln(1+u) = u - u²/2 + u³/3 - u⁴/4 + …,收敛区间 |u| < 1。这个才是真正能拿去算的定义,计算机和手算走的都是这条路。三种定义在数学上完全等价,但工程上只有一个能用:凡是“解方程”形式的定义,都必须转化成级数或者迭代才能落地。这就是为什么懂一点数值方法的人算 ln 会比别人快——他知道该往哪个形式上去凑。
顺便说一句,ln 和以 10 为底的对数 lg、以 2 为底的对数 log2,本质上是同一个函数乘一个常数:log_a x = ln x / ln a。所以计算机内部通常只实现一个高精度的 ln(或者 log2),其他底数都是除一下得到。理解了这一点,后面看到各种库函数的实现就顺了。
1.2 为什么不同工具算出来的“看起来不一样”
最常见的一个误会是“计算器算错了”。拿 ln 2 举例,它的真值是 0.693147180559945309417232121458…,一个无限不循环小数。你在不同地方看到的版本是:
| 场景 | 显示结果 | 原因 |
|---|---|---|
| 普通科学计算器(8 位显示) | 0.6931472 | 只显示 8 位有效数字,末位四舍五入 |
| 手机计算器横屏(12 位) | 0.69314718056 | 显示位数更多 |
Pythonmath.log(2) | 0.6931471805599453 | 双精度浮点,约 16 位有效数字 |
C 语言logf(2.0f) | 0.6931472 | 单精度浮点,只有约 7 位有效数字 |
Excel=LN(2) | 0.693147181 | 显示 9 位小数,内部仍是双精度 |
这些结果没有一个是“错的”,差别只在于精度和显示位数。真正需要注意的是单精度和双精度的差距:单精度只有 24 位二进制尾数,约合 7 位十进制有效数字,如果你用它做需要 10 位精度的计算,误差会滚雪球。我在一个图形渲染的项目里见过有人用 float 存对数结果,然后在后面做了七八次累加,最后误差到了小数点后第三位,画面上的亮度曲线肉眼可见地歪了。
还有一个更隐蔽的差异来源:某些工具在 x 接近 1 时会给出“看起来不对”的结果。比如 ln(1.0000001),真值是 9.9999995e-8,但如果你先算 1 + 1e-7 再取对数,中间那步加法本身的舍入误差就会污染结果。这不是 log 函数的问题,是输入就已经损失了信息。这一类问题的解法是换用专门的 log1p 函数,后面第 5 章会专门讲。
提示:判断一个 ln 结果是否可信,别看它小数点后有多少位,看它的相对误差。ln 2 的相对误差 1e-16 意味着绝对误差 7e-17;而 ln(1e-8) 的绝对值只有 -18.4,同样 1e-16 的相对误差对应的绝对误差只有 2e-15。量级不同,容忍度也不同。
2. 数学层面:不靠计算机,lnx 是怎么一步步算出来的
2.1 泰勒级数的坑:收敛慢和定义域限制
很多人第一次学 ln 的展开就是 ln(1+u) = u - u²/2 + u³/3 - …,然后兴冲冲地拿 u = 1 去算 ln 2,结果发现算了 100 项才精确到小数点后两位。这不是算错了,是这个级数在 u = 1 处收敛得极慢,而且它是交错级数,项值一正一负互相抵消,有效位数损失严重。想精确到 1e-10,理论上需要约 10^10 项,纯属开玩笑。
问题出在收敛半径和收敛速度上。这个级数的收敛半径是 1,u = 1 正好在边界上,属于条件收敛,速度是 O(1/n)。u 越小收敛越快:u = 0.1 时,u^n 每项缩小 10 倍,算 8 项就能到 1e-9。所以关键不在于级数本身,而在于能不能把任意一个 x 变换到“离 1 很近”的位置上。这就是区间压缩的动机。
另一个常被忽略的点是精度损失。当 u 很小时,u - u²/2 里第一项和第二项量级差很多,直接相加没问题;但当 u 接近 1 时,各项量级相当,交错相减会出现严重的“大数相减”问题。计算机做浮点减法时,如果两个数很接近,结果的相对误差会被放大。所以哪怕你能忍受算一亿项,最后的结果也未必准。区间压缩既解决了速度问题,也顺带解决了精度问题。
2.2 区间压缩:把任何 x 搬到离 1 最近的地方
核心思路是利用对数的性质 ln(a·b) = ln a + ln b 和 ln(2^k) = k·ln 2,把任意正数拆成“一个方便计算的尾数 × 2 的整数次幂”。具体做法是:把 x 写成 x = m · 2^e,其中 m 落在 [0.5, 1) 或者更讲究一点落在 [√2/2, √2) 区间内,e 是整数。这样 ln x = e · ln 2 + ln m,而 ln 2 是常数(0.6931471805599453),只需要算 ln m。
为什么选 [√2/2, √2) 这个区间?因为接下来要用 ln m = 2·atanh((m-1)/(m+1)),也就是令 y = (m-1)/(m+1),则 ln m = 2(y + y³/3 + y⁵/5 + …)。这个级数收敛速度取决于 |y|。当 m 在 [0.5, 1) 时,y 的取值范围是 [-1/3, 0),最坏情况 |y| = 1/3,y² = 1/9,每项衰减约 9 倍,要算到 1e-17 需要约 18 项。当 m 被压到 [√2/2, √2) 时,最坏 |y| ≈ 0.1716,y² ≈ 0.0294,每项衰减约 34 倍,只需要 11 项左右。
这个细节上的优化,在库函数里是标配,在你自己写代码时值得抄。除了 2 的幂,也可以按 10 的幂拆:x = a × 10^k,ln x = k·ln 10 + ln a,a 落在 [1, 10)。这种做法对手算特别友好,因为人对十进制更敏感,而且 ln 10 = 2.302585092994046 是常用常数,心算时取 2.3026 就够。
2.3 牛顿迭代与算术几何平均:另外两条路
牛顿迭代的思路是解方程 f(y) = e^y - x = 0,迭代式 y_{n+1} = y_n - (e^{y_n} - x)/e^{y_n} = y_n + x·e^{-y_n} - 1。这个方法收敛是二次的,也就是每迭代一次有效位数翻倍,从 6 位到 12 位再到 24 位,三四次就够。代价是每次迭代都要算一次 exp,而 exp 本身也是级数算出来的,等于用另一个级数问题换掉了当前问题。实际工程里很少单独用牛顿法算 log,但在需要同时算 exp 和 log 的场合(比如某些特殊函数库),会共享中间结果。
算术几何平均(AGM)是另一条路,它基于 ln x 和椭圆积分的关系,迭代序列 a_{n+1} = (a_n + b_n)/2,b_{n+1} = √(a_n·b_n),收敛速度极快,每轮迭代有效位数翻倍甚至更好。高精度计算库(比如需要几百上千位小数的场景)偏爱这个方法,因为它只需要开方和平均,没有级数求和的长尾。缺点是常数项和初始值的处理比较绕,普通工程用不上。
还有一条路是 CORDIC 算法,本质是把对数运算拆成一串固定的加减和移位,靠查表和角度累加逼近期望值。它在没有硬件乘法器的嵌入式芯片上很吃香,一套移位加法的电路就能同时算 sin、cos、exp、log。如果你写过 FPGA 或者用过老式单片机,大概率见过它的影子。
2.4 手算实例:ln2 和 ln10 到底怎么凑出来
说理论不如直接算。ln 2 用 atanh 级数最快:注意 2 = (1 + 1/3)/(1 - 1/3),所以 ln 2 = 2·atanh(1/3) = 2(1/3 + 1/(3·3³) + 1/(5·3⁵) + …)。逐项列出:
- 第 1 项:1/3 = 0.3333333333
- 第 2 项:1/(3·27) = 1/81 = 0.0123456790
- 第 3 项:1/(5·243) = 1/1215 = 0.0008230453
- 第 4 项:1/(7·2187) = 1/15309 = 0.0000653220
- 第 5 项:1/(9·19683) = 1/177147 = 0.0000056450
- 第 6 项:1/(11·177147) = 1/1948617 = 0.0000005132
把这六项加起来得 0.3465735,乘 2 得 0.6931470。真值是 0.6931472,六项已经对了六位。这个例子的价值在于它说明了“选对变换形式”有多重要——同样的级数,直接拿 u = 1 去算要算一亿项,换成 atanh 形式六项搞定。
ln 10 可以用 10 = 8 × 1.25,所以 ln 10 = 3·ln 2 + ln 1.25。而 ln 1.25 = 2·atanh(1/9) = 2(1/9 + 1/(3·729) + 1/(5·59049) + …) ≈ 2(0.1111111 + 0.0004572 + 0.0000034) ≈ 0.2231434。加上 3 × 0.6931472 = 2.0794416,得 2.3025850,和真值 2.3025851 差一位。这套流程熟练之后,随手估一个 ln 值只要两三分钟。
心得:手算时不要追求一次到位,先算到三四位,再决定要不要继续。绝大多数实际问题的答案只要 2 到 3 位有效数字,没有计算器的时候,ln 2 ≈ 0.693、ln 10 ≈ 2.303、ln 3 ≈ 1.099、ln 5 ≈ 1.609 这四个数就够应付九成场景。
3. 计算机内部:按下 ln 键之后发生了什么
3.1 浮点数的位结构与“拆”的天然便利
现代计算机里的 double 遵循 IEEE 754 标准:1 位符号、11 位阶码、52 位尾数。一个正数 x 被表示成 x = 1.f × 2^(E-1023) 的形式,其中 1.f 是隐含了前导 1 的尾数部分。这个结构对算 log 来说几乎是量身定做的:以 2 为底的对数,整数部分直接就等于阶码减去偏置值,只需要对尾数部分 [1, 2) 做级数或者多项式逼近。
换句话说,计算机算 log 的第一步不是数学运算,而是“拆位”——从内存里把那 64 个比特读出来,掩码取阶码,掩码取尾数。这一步没有任何浮点运算,纯粹是位操作,速度快到可以忽略。很多人以为浮点函数是一大坨数学公式,其实前面几百纳秒都花在拆位和查表上,真正做多项式求值的部分反而很短。
拆完之后,x = m · 2^e,ln x = e·ln 2 + ln m,m 落在 [1, 2)。按照第 2 章的分析,m 还可以再规范化一次——如果 m > √2,就把 m 除以 2、e 加 1,让 m 落进 [√2/2, √2),这样后面的级数收敛更快。这一步是一个简单的比较和条件分支,代价几乎为零。
3.2 查表加多项式:工业级实现的标准套路
真正的主菜是 ln m 的计算。主流数学库(glibc、musl、MSVC 的 CRT)普遍采用“查表 + 多项式”的混合方案。具体做法是把区间 [√2/2, √2) 再细分成若干段,用 m 的高几位作为索引去查表,取出一个“接近 m 的参考点”以及对应的 log 值,然后计算 δ = m - 参考点,用一个小多项式逼近 ln(1 + δ/参考点)。δ 越小,多项式需要的阶数越低。
表的大小是个权衡。表大一点,多项式阶数就能降,整体可能更快;但表大了缓存不友好,反而变慢。常见配置是 16 到 128 项的表格,配一个 5 到 8 阶的多项式。系数不是用泰勒级数算的,而是用 minimax 逼近(雷米兹算法)求出来的,它在整个区间上把最大误差压到最低,而不是在展开点附近误差最小——这两种目标在数值上完全不同,泰勒系数用作近似往往比 minimax 差好几倍。
用 minimax 多项式逼近的好处是可以严格控制误差上限。数学库的目标通常是误差不超过 1 个 ulp(unit in the last place,末位单位),也就是结果和真值之间的差不超过最后一位的半个到一个单位。双精度下 1 个 ulp 对应的相对误差约 2.2e-16。实际实现里,glibc 的 log 误差控制在 0.5 到 1 ulp 之间,已经非常接近理论极限了。
3.3 误差从哪来:三个互相独立的来源
第一个来源是多项式截断误差。不管你用几阶多项式,总会剩下一个尾巴,这个尾巴的大小由逼近算法和阶数决定。第二个来源是常数 ln 2 的表示误差。ln 2 在双精度里存成 0.693147180559945309417232121458 的近似值,本身就有约 1e-17 的误差,乘上阶码 e(最大约 1024)之后,绝对误差能放大到 1e-14 量级。第三个来源是最后的加法舍入:e·ln 2 和 ln m 量级可能差很多,相加时小的那部分低位会被吃掉。
这就是为什么 ln 的精度在 x 很大或很小时会略微下降。举个具体例子,ln(1e308) ≈ 709.1962,而 ln(1e-308) ≈ -709.1962。709 这个量级意味着双精度浮点在这个尺度上的最小间隔是 2^-52 × 512 ≈ 1.1e-13,所以结果的绝对精度不可能优于 1e-13,即便相对精度仍是 1e-16。如果你在做需要绝对精度的数值计算(比如某些累积求和),这个细节会咬人。
注意:绝对精度和相对精度不是一回事。双精度浮点的相对精度恒定在 1e-16 左右,但绝对精度随着数值量级线性变化。判断一个 log 结果够不够用,先看你的下游计算需要的是相对误差还是绝对误差。
3.4 硬件指令和软件库的分工
x87 时代的浮点协处理有一条 FYL2X 指令,直接算 y·log2(x),一条指令搞定。这条指令现在还在,但速度并不比软件实现快多少,而且精度只有 64 位扩展精度,现代编译器默认不用它。SSE、AVX 指令集里没有对数指令,所有 log 调用最终都落到软件库上。
GPU 是另一个故事。显卡上的 log 通常用近似指令实现(比如某些架构有 lg2 近似指令,精度只有 22 位左右),速度极快但精度有限。图形渲染里这么做完全够用,因为像素颜色只差几个灰度级没人看得出来。但如果你拿 GPU 算科学计算,就得小心这些低精度指令的误差累积——这也是为什么 GPU 上的科学计算库往往自己实现一套高精度 log,而不是直接用硬件指令。
4. 落地实操:不同场景下 lnx 到底怎么写
4.1 Python:先分清 log、log2、log10、log1p
Python 的math模块里,math.log(x)默认就是自然对数,这一点和很多人的直觉相反——不少人以为 log 是十进制的。math.log(x, base)可以指定底数,但内部实现是log(x)/log(base),会引入额外的舍入误差,能不用就不用。算以 2 为底用math.log2,以 10 为底用math.log10,这两个是专门优化的,精度更高。
import math print(math.log(2)) # 0.6931471805599453,自然对数 print(math.log2(8)) # 3.0,以 2 为底 print(math.log10(1000)) # 3.0,以 10 为底 print(math.log1p(1e-16)) # 1e-16,小量专用 print(math.log(1 + 1e-16))# 0.0,加法那一步已经把 1e-16 吃掉了最后两行的对比值得反复看。1 + 1e-16在双精度下等于 1.0,因为 1e-16 小于 1 附近的机器精度 2.2e-16,加法直接把它舍掉了。所以math.log(1 + 1e-16)返回 0.0,而math.log1p(1e-16)返回 1e-16。这不是 bug,是浮点表示能力的物理极限,任何语言都一样。
需要更高精度时,可以上decimal模块,Python 3.3 之后Decimal自带ln()方法:
from decimal import Decimal, getcontext getcontext().prec = 50 print(Decimal(2).ln()) # 0.69314718055994530941723212145817656807550013436026再往上就是mpmath了,它能算到几千位,代价是速度慢几个数量级,只在验证算法或者做符号计算时用。
4.2 C、C++、Java、JavaScript:函数名一样,坑不一样
C 语言里log是自然对数,log10是常用对数,log2是 C99 之后才有的。单精度用logf,长双精度用logl。这几个函数在<math.h>里,链接时需要加-lm(某些平台)。
#include <math.h> #include <stdio.h> #include <errno.h> int main(void) { printf("%.17g\n", log(2.0)); // 0.69314718055994529 printf("%.17g\n", log1p(1e-16)); // 1e-16 errno = 0; double r = log(0.0); // -inf,同时 errno = ERANGE printf("%f %d\n", r, errno); return 0; }log(0)在 C 里返回负无穷并设置errno,log(-1)返回 NaN 并设置errno = EDOM。这两个行为是 IEEE 754 和 C 标准共同规定的,跨平台基本一致。Java 的Math.log语义相同,Math.log1p也有。JavaScript 的Math.log同样是自然对数,Math.log2、Math.log10、Math.log1p都在 ES6 之后标准化了。
真正容易出岔子的是嵌入式平台的数学库。有些精简版 libm 为了省空间,把 log 实现成精度很低的近似,误差可能到 1e-7 甚至更大。我在一个 ARM Cortex-M 的项目上就遇到过,同一段代码在 PC 上跑得好好的,烧进板子以后结果在小数点后第五位开始飘。查了半天才发现是链接了精简版的数学库。解决办法要么换成完整版,要么自己实现一个查表加多项式的版本。
4.3 Excel 和计算器:别把 LN 和 LOG 搞反
Excel 里=LN(x)是自然对数,=LOG(x)默认以 10 为底,=LOG(x, 2)是以 2 为底,=LOG10(x)也是以 10 为底。这个设计坑过无数人——因为很多编程语言里 log 默认是自然对数,而 Excel 里 log 默认是十进制的。我见过有人用=LOG(2)想算 ln 2,得到 0.3010 还以为自己算错了。
Windows 自带的计算器,切到“科学”模式,ln 键就是自然对数,log 键是以 10 为底。手机上横屏切换到科学计算器,布局类似。要注意的是运算顺序:大部分计算器是“先输入数字,再按 ln”,也就是后缀表达式;而有些老式计算器是“先按函数,再输入数字”。这个差异在连续运算时特别容易出错。
还有一个常被忽略的:计算器的角度/弧度制开关不影响 ln,只影响三角函数。但如果你在算 e^x 的时候误触了某个模式,可能会得到莫名其妙的结果。养成习惯,每次算完看一眼量级对不对。
4.4 自己写一个 log:把前面的理论串起来
下面这段代码用第 2 章的思路实现自然对数,可以直接跑,也可以拿去对照标准库。
import math LN2 = 0.693147180559945309417232121458 def my_ln(x, tol=1e-17): if x <= 0: raise ValueError("x 必须为正数") m, e = math.frexp(x) # x = m * 2**e, m 在 [0.5, 1) if m < 0.7071067811865476: # 压到 [sqrt(2)/2, sqrt(2)) m *= 2.0 e -= 1 y = (m - 1.0) / (m + 1.0) y2 = y * y term = y s = 0.0 k = 1 while True: s += term / k term *= y2 k += 2 if abs(term / k) < tol: break return 2.0 * s + e * LN2关键点有三个。math.frexp直接帮你完成了“拆成 m 和 e”这一步,等效于前面说的拆位,但它是标准库函数,跨平台可靠。区间判断用 √2/2 对应的十进制近似 0.7071067811865476,这个常数在双精度下足够精确。循环终止条件用abs(term/k) < tol,而不是固定迭代次数,这样对不同输入自适应,也不会因为次数不够导致精度损失。
实测下来,这个实现和math.log的差值普遍在 1e-16 量级,也就是 0 到 2 个 ulp 的差距,对于教学和一般工程完全够用。速度上比标准库慢一到两个数量级,因为标准库做了查表和多项式优化,而这里每次都要跑循环。真要追求速度,把 atanh 级数换成 minimax 多项式就能快好几倍。
| x | 参考值(高位精度) | math.log | 自实现my_ln |
|---|---|---|---|
| 2 | 0.6931471805599453 | 0.6931471805599453 | 0.6931471805599453 |
| 10 | 2.302585092994046 | 2.302585092994046 | 2.302585092994046 |
| 3 | 1.0986122886681098 | 1.0986122886681098 | 1.0986122886681098 |
| 1.5 | 0.4054651081081644 | 0.4054651081081644 | 0.4054651081081644 |
| 0.001 | -6.907755278982137 | -6.907755278982137 | -6.907755278982137 |
4.5 批量计算:向量化不是玄学
如果要算几十万个数的 ln,Python 的循环会慢得让人想砸键盘。换成 numpy 的np.log(arr),底层走的是 SIMD 指令和 C 循环,速度能快三四十倍。这不是 numpy 的 log 更聪明,纯粹是循环开销的差异。
import numpy as np, math, time data = np.random.uniform(0.1, 100, 1_000_000) t0 = time.perf_counter() r1 = np.log(data) t1 = time.perf_counter() t2 = time.perf_counter() r2 = [math.log(v) for v in data] t3 = time.perf_counter() print(f"numpy: {t1-t0:.4f}s 纯循环: {t3-t2:.4f}s")实测在普通笔记本上,numpy 处理一百万个点大约几十毫秒,纯 Python 循环要一两秒。如果数据量再大,还得考虑内存布局和缓存命中,但那是另一个话题了。这里只说一点:如果你的瓶颈真的是 log 本身而不是循环,那说明你已经在做非常底层的优化了,该考虑的是换语言或者上 SIMD,而不是继续调库函数。
5. 常见问题与排查技巧实录
5.1 log 到底指哪个底:语言差异速查
这是最高频的坑,没有之一。同一个log在数学教材、编程语言、Excel、计算器上含义完全不同。
| 环境 | log(x)含义 | 自然对数怎么写 | 以 10 为底怎么写 |
|---|---|---|---|
| 中文数学教材 | 通常指以 10 为底 | ln(x) | lg(x) |
Pythonmath | 自然对数 | math.log(x) | math.log10(x) |
C/C++<math.h> | 自然对数 | log(x) | log10(x) |
JavaMath | 自然对数 | Math.log(x) | Math.log10(x) |
JavaScriptMath | 自然对数 | Math.log(x) | Math.log10(x) |
| Excel | 以 10 为底 | LN(x) | LOG(x)或LOG10(x) |
| 科学计算器 | 以 10 为底(多数) | ln键 | log键 |
排查方法很简单:拿 x = 10 代进去试一下。结果是 1 就是以 10 为底,是 2.3026 就是自然对数,是 3.3219 就是以 2 为底。这三个数记一个就够,我一般记 2.3026。
5.2 零、负数、无穷大:边界情况的行为差异
ln(0)在数学上是负无穷,ln(负数)在实数范围内无定义。但不同工具的处理方式不一样,写代码时必须知道你的运行环境怎么处理。
| 输入 | Pythonmath.log | numpynp.log | Clog | ExcelLN |
|---|---|---|---|---|
| 0 | 抛ValueError | 返回-inf,警告 | 返回-inf,设 errno | 返回#NUM! |
| -1 | 抛ValueError | 返回nan,警告 | 返回 NaN,设 errno | 返回#NUM! |
| inf | 返回inf | 返回inf | 返回inf | 返回#NUM! |
| nan | 返回nan | 返回nan | 返回 NaN | 返回#NUM! |
这个差异在数据清洗时特别致命。用 Python 的math.log处理一批含零的数据,第一个零就抛异常,程序直接挂;换成 numpy 会得到-inf和警告,程序能跑完,但下游如果拿-inf去做加法,整个数组都变-inf。稳妥的做法是先做一次掩码过滤,把非正数挑出来单独处理,别指望库函数替你兜底。
import numpy as np data = np.array([1.0, 0.0, -1.0, 2.0]) safe = data[data > 0] result = np.full_like(data, np.nan) result[data > 0] = np.log(safe)这段代码的思路是“先标记、再计算”,避免非法值污染整个结果。多写两行,省下后面两小时的调试。
5.3 log1p 与精度陷阱:小量必须特殊照顾
前面提过log(1 + x)在 x 很小时会丢精度,原因有两个:加法1 + x本身会舍入,x 小于 1.1e-16 时直接被吃掉;即使没被吃掉,1 和 x 量级差太大,结果的有效位数也会减少。log1p(x)专门解决这个问题,它内部用另一套变换(通常是 atanh 形式)直接算 ln(1+x),绕过加法那一步。
什么时候该用 log1p?判断标准很简单:如果 x 可能小于 1e-3,就用 log1p。典型场景包括计算微小收益率、连续复利、概率的对数几率、以及任何“1 + 一个小量”的结构。机器学习里算交叉熵经常遇到 p 接近 0 的情况,直接log(p)会得到负无穷,而用log1p(-p)或者加一个 epsilon 平滑就稳得多。
真实的反例:有个做金融的同学算日收益率 r = 1e-8,然后用log(1 + r)转成对数收益率。双精度下1 + 1e-8能表示,结果也还行;但换成日收益率 1e-17(某些高频场景真的会出现),加法直接返回 1.0,log 得到 0,整条曲线的信息全丢了。换成log1p(r)就没问题。
5.4 大数、次正规数和溢出
双精度能表示的最大值约 1.8e308,所以ln的最大返回值约 709.78。这个范围看着很大,但在某些物理模拟或者概率计算里,中间结果很容易爆掉。经典场景是算似然函数,几百个概率相乘很快下溢到 0,取对数之后就变成几百个负数相加,稳稳当当。这也是对数似然比原始似然更常用的原因之一。
次正规数是另一个边界。双精度的最小正规数约 2.2e-308,再往下到 5e-324 之间是次正规数,精度会逐渐损失。ln(5e-324)约等于 -744.44,这个值本身能算出来,但输入 x 的有效位数已经只剩几位了,结果的精度自然也好不到哪去。如果业务上真的要处理这么小的数,建议提前做尺度变换,别硬扛。
还有一个隐蔽的溢出:exp(log(x))不一定等于 x。中间过程如果 log(x) 接近 709,exp 回去会溢出成 inf;如果 log(x) 接近 -745,exp 回去会下溢成 0。这类问题在概率计算链里很常见,标准解法是在对数域里一路算到底,最后再决定要不要 exp 回去。
5.5 问题速查表
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 结果和教材差十倍 | 底数搞混了 | 用 x = 10 代入验证 |
| 小量输入结果全是 0 | 直接算了log(1+x) | 换成log1p(x) |
大批数据里出现-inf | 数据含 0 或负数 | 先掩码过滤再计算 |
| 结果只有 7 位有效数字 | 用了 float 单精度 | 换 double 或 long double |
| 和别的平台结果差 1e-15 | libm 实现不同 | 一般可接受,需比对时统一环境 |
| 循环里越算越偏 | exp/log 反复往返 | 改在对数域内计算 |
| 嵌入式上精度骤降 | 链接了精简版数学库 | 换完整实现或自己写 |
6. 把 ln 用起来:几个真实出现的场景
6.1 熵、交叉熵与对数几率
信息熵的公式 H = -Σ p_i · log2(p_i),这里的 log 常取以 2 为底,单位是比特;统计学习里更常用自然对数,单位是纳特。两者只差一个常数因子 ln 2 ≈ 0.693。写代码时最忌讳的是把两种混用——模型训练用自然对数算损失,评估指标却按比特报,数字对不上还以为是模型出了问题。
交叉熵损失里有个经典陷阱:p 可能等于 0。直接算log(0)得到-inf,反向传播时梯度变成 NaN,训练直接崩。工业界的标准做法是给概率加一个极小的 epsilon(比如 1e-12)平滑,或者用框架内置的binary_cross_entropy_with_logits这类数值稳定版本。后者内部用的是 log-sum-exp 技巧,把log(sigmoid(x))展开成-log(1+exp(-x)),在 x 很大或很小时都不会溢出。
对数几率 logit 函数 ln(p/(1-p)) 也是同样的道理。p 接近 0 或 1 时,直接算会得到 ±inf,稳妥的写法是用log(p) - log1p(-p),或者干脆在对数空间里操作。
6.2 log-sum-exp:概率计算里的保命技巧
概率连乘容易下溢,取对数变成连加能撑住。但有时候需要“先乘后加”——也就是算 Σ p_i,在对数域里就是 log(Σ exp(x_i))。麻烦在于 exp(x_i) 可能溢出。解决办法是先减去最大值:
import numpy as np def logsumexp(x): m = np.max(x) return m + np.log(np.sum(np.exp(x - m)))减去最大值之后,exp(x - m)的最大值是 1,绝不会溢出;其他的项都是小于 1 的正数,加起来的精度也有保证。这个技巧在混合模型、注意力机制、贝叶斯推断里到处都在用,几乎算是标配。我第一次见的时候觉得平平无奇,后来自己写了一个不加 m 的版本,输入向量里有个 800,直接 inf 溢出,才明白这几行代码的价值。
6.3 对数坐标与数据可视化
画图时数据跨好几个数量级,线性坐标会把小值全压成一条线,这时候要换成对数坐标。Python 的 matplotlib 用plt.xscale('log')或plt.yscale('log'),Excel 里在坐标轴格式里勾选“对数刻度”。注意对数坐标不能画 0 和负数,遇到这类数据要么剔除,要么加一个偏移量。
还有一个细节:对数坐标下的等距刻度对应的是等比数列,所以网格线的位置在视觉上是不均匀的。人类对这种不均匀的刻度其实不太敏感,容易误判斜率。同样的“看起来是直线”,在对数坐标下意味着指数增长,在线性坐标下意味着匀速增长,两者天差地别。做数据汇报时如果用了对数坐标,记得在图上标清楚,别让人误读。
6.4 复利、半衰期和规模估算
ln 在现实问题里出现得比想象中频繁。复利计算里,本金翻倍需要的时间 t = ln 2 / ln(1 + r),r 很小时近似为 0.693/r。金融行业流传的“72 法则”就是把这个式子简化:t ≈ 72 / (r 的百分数)。用 72 而不是 69.3,是因为 72 能被 2、3、4、6、8、9、12 整除,心算方便,代价是 r 在 8% 附近时误差稍大。
放射性半衰期、药物代谢、缓存淘汰里的热度衰减,用的都是同一个指数衰减模型,转换公式也是 ln 2 除以衰减率。软件工程里估算系统规模增长时间,也常用这个套路——年增长率 20%,翻倍需要 ln 2 / ln 1.2 ≈ 3.8 年。这类估算不需要精确到小数点后几位,记住 0.693 这个数,再记住 ln(1+x) ≈ x 对小量成立,八成场景都能口算出来。
最后分享一个我自己常用的心算方法:任何数先写成 a × 10^k 的形式,a 落在 [1, 10),然后 ln x = k × 2.3026 + ln a。而 ln a 可以用几个锚点插值——ln 1 = 0、ln 2 = 0.693、ln 3 = 1.099、ln 5 = 1.609、ln 7 = 1.946、ln 10 = 2.303。比如 5000 = 5 × 10³,ln 5000 ≈ 3 × 2.3026 + 1.609 = 8.517,真值 8.5172,误差在千分之一以内。这套方法在没计算器、又需要快速判断量级的场合,比掏出手机还快。