阶乘与阶数:机器学习中的增长规律、复杂度估算与数值稳定性实践
1. 阶乘和阶数到底在讲什么:先把概念边界划清
做机器学习的人,日常打交道最多的是矩阵、梯度,以及损失下降的曲线,阶乘和阶数这两个词反而显得偏数学教材。但只要稍微往深处走一点,它们就会反复出现:算模型参数量的时候,你会被增长曲线吓一跳;推泰勒近似的时候,分母上那个 1/n! 没法绕开;判断一个模块能不能扩展到更大输入时,你说的“这个是大 O 的 n 平方”,其实就是在谈阶数。这一篇想做的事很朴素——把阶乘和阶数这两个概念彻底拆开,讲清它们各自的定义、增长规律、计算方法,以及它们怎样在实际的模型设计和代码里影响你的判断。
如果你是从头跟这个系列的,前面的极限、导数、泰勒、多元微分都打过照面了,这一篇算是把这些工具串起来的一次综合练习。如果你是新来的,只要会基本的求导、写过几行 Python,也能跟下来,我会把关键的中间步骤都补齐。阶乘负责描述“组合爆炸”这类极端增长的量级,阶数负责描述“谁比谁涨得快”这种相对关系,两者配合起来,你才能在动手之前就预判一个方案会不会撑爆内存、会不会算到天荒地老。
1.1 阶乘的增长有多猛:先做一次心算实验
先别急着看公式,做个实验更有体感。5! 是 120,10! 是 3628800,20! 大约是 2.43×10¹⁸,30! 已经到了 2.65×10³²。这不是“慢慢变大”,而是每一格都在乘上当前这个数。我习惯用一个生活类比:指数增长像“每次翻倍”,阶乘增长像“每次翻的倍数还在变大”。2 的 n 次方在 n=30 时大约是 10 的 9 次方,而 30! 是 10 的 32 次方,差着二十多个数量级。
这件事在工程上的直接后果就是:阶乘量级的算法,任何超过二十几的输入都不能硬算。全排列枚举、旅行商暴力解、把 n 个元素排成一列的所有方案,这些都是 n! 量级。写代码验证一下非常直观:
import math for n in [5, 10, 15, 20, 21, 25]: try: print(n, math.factorial(n)) except OverflowError as e: print(n, "overflow", e)跑完你会发现,Python 的整数是任意精度的,所以 math.factorial 不会溢出,只是数字越来越长。但你要是用 numpy 的 int64 去存,20! 是 2432902008176640000,还没超过 int64 上限 9223372036854775807;21! 变成 51090942171709440000,直接就溢出了。这个临界点在真实工程里出现得非常频繁——凡是有人写“先算个阶乘再除”的代码,我都会先去看他有没有在 n 超过 20 的地方翻车。
注意:整数溢出的位置取决于位宽,32 位整数在 12! 附近就爆了(12! ≈ 4.79×10⁸ 还在 2³¹ 内,13! ≈ 6.23×10⁹ 已超过 2.1×10⁹)。跨平台代码里用固定位宽整数处理阶乘,是最容易埋雷的地方。
1.2 阶数的四张面孔:多项式阶、无穷大阶、导数阶、张量阶
“阶数”这个词在机器学习语境里至少有四种不同用法,混着用特别容易出误解,我先把它们分开。
第一张面孔是多项式的阶。一个关于 x 的多项式,最高次项的次数就是它的阶。二次多项式是 2 阶,三次是 3 阶。这层含义在特征工程里很常见,比如多项式特征展开,你说“做二阶交叉特征”,指的就是把 x₁x₂ 这类二次项加进去,特征维度从 d 涨到大约 d²/2。
第二张面孔是无穷大(小)的阶。当 x→0 或 x→∞ 时,比较两个函数谁更快趋于零或无穷,用同阶、高阶、低阶、等价阶来描述。这是极限论里的核心语言,也是复杂度分析的基础。常见的等价关系有一张表,比如 x→0 时 sin x ~ x、1-cos x ~ x²/2、ln(1+x) ~ x。
第三张面孔是导数的阶。一阶导是斜率,二阶导是曲率,三阶导描述曲率的变化。牛顿法用二阶信息,拟牛顿法用近似二阶,梯度下降只用一阶。多变量情形下,“二阶”对应的是 Hessian 矩阵,它的规模是 d×d。
第四张面孔是张量的阶(rank/order)。零阶张量是标量,一阶是向量,二阶是矩阵,三阶以上就是高维数组。这层含义直接影响内存占用和算子实现,在框架里写dim=3的卷积核,就是在处理四阶张量(batch、通道、高、宽)。
这四张面孔共用“阶”这个字,但数学含义完全不同。我的经验是:跟别人讨论时,第一次出现就补一句“这里说的阶是导数阶还是复杂度阶”,能省掉后面大量来回确认。
1.3 机器学习里绕不开它们的四个现场
为什么值得单独花一章讲这两件事?因为它们出现在四个高频现场。
现场一:模型容量估算。层数、宽度、注意力头数增加时,参数量和计算量按什么规律涨?多层的堆叠是线性的,宽度的平方在部分结构里出现,而某些结构(比如全连接层的堆叠)如果逐层都是 d×d,那总参数量是 O(Ld²)。搞清楚这个阶,你才知道加大宽度和加深层数哪一个更“贵”。
现场二:泰勒近似与激活函数。为了加速推理,有人把 exp、tanh 换成多项式近似。近似到几阶才够?误差项长什么样?这里 1/n! 和阶数判断是绑在一起的。
现场三:组合搜索空间。超参搜索、结构搜索、排列式数据增强,方案数量经常是阶乘或指数级的,先估阶数再动手,能避免写出跑不完的脚本。
现场四:数值稳定性。阶乘在概率公式里大量出现(泊松、多项分布、Beta 分布),直接算必然溢出,必须转成对数域。这个技巧本身不复杂,但不知道就会卡很久。
2. 阶乘的硬核细节:从整数到实数,从精确到近似
2.1 Gamma 函数:把阶乘搬到连续轴上
阶乘天然只定义在非负整数上,但机器学习里的很多公式需要在实数甚至复数上取值。把阶乘连续化的工具就是 Gamma 函数,定义为 Γ(z) = ∫₀^∞ t^(z-1) e^(-t) dt,对于正整数满足 Γ(n) = (n-1)!。注意这个偏移一位,是很多人第一次用会写错的地方——你要算 n! 得用 Γ(n+1)。
这个函数为什么重要?因为 Beta 分布、Dirichlet 分布、卡方分布、t 分布的归一化常数里都有它。做贝叶斯推断时,后验分布常常是 Dirichlet 形式,参数更新就是简单的加法,但你要算对数似然或者做变分推断时,就得对 Gamma 函数取对数。这时如果直接算 Γ(1000),结果是 10 的 2567 次方量级,任何浮点类型都存不下。
从微积分角度看,Gamma 函数还有一个让人舒服的性质:它的对数(常叫 digamma 之外的那个 log-gamma)是凸函数。这个凸性在变分推断的优化里有实际价值,因为它保证了某些目标函数是良态的,不会出现多重局部极小的乱象。我在看变分自编码器相关推导时,第一次意识到这个凸性原来不是纯理论摆设,而是决定了优化过程稳不稳。
2.2 斯特林公式:不用计算器也能估量级
真要在脑子里估一个阶乘的量级,斯特林公式是首选:n! ≈ √(2πn) · (n/e)^n。取对数后更清爽,ln(n!) ≈ n ln n - n + 0.5 ln(2πn)。
我做过一次对比,把 n 从 5 到 100 逐个算精确值和对数近似值,误差在 n=10 时大约是 0.8%,n=50 时降到 0.03% 以下。也就是说,只要 n 超过十,用斯特林估量级基本够用。
这个公式的实用价值在于“先想清楚量级,再决定要不要写代码”。举个例子,你在设计一个需要对所有排列做加权的模块,输入长度是 30,那方案总数是 30!,约 2.65×10³²。就算每个方案只花一纳秒,也要 8×10¹⁵ 年。这种情况下不需要跑基准测试,直接换算法就行。我见过不少项目在这一点上浪费了一两周,就是因为没人先做这个除法。
提示:斯特林公式在 n 很小时不准,n=1 时算出来约 0.922,实际是 1;n=2 时约 1.919,实际是 2。小 n 直接查表更省事,大 n 才用近似。
2.3 泰勒展开里的 n!:为什么分母总跟着阶乘
泰勒公式写成 f(x) = Σ_{k=0}^{n} f^(k)(a)/k! · (x-a)^k + R_n(x)。那个 k! 不是天上掉下来的,它的作用是“抵消掉反复求导带来的系数膨胀”。你可以这样理解:每求一次导,x^k 就会掉下来一个 k,为了让它回到原样,必须除以 k 的累乘。
这个细节在工程上意味着两件事。第一,阶数越高,系数衰减越快,所以高阶项通常贡献很小,这也是低阶近似能work的根本原因。第二,收敛半径和阶数是两个独立的概念,阶数再高,如果 x 超出了收敛半径,近似照样发散。这一点在把 exp 展开成多项式近似时特别容易踩:在 x=0 附近展开 10 阶非常准,但把同一个多项式用在 x=20 上,结果会离谱到没法看。
我做过一次实测,用 6 阶泰勒多项式近似 e^x,在 x∈[-1,1] 区间最大相对误差约 10⁻⁵,在 x=3 附近误差就上到百分之几了。所以做激活函数近似时,一定要先确定输入的取值范围,再决定阶数,而不是盲目堆阶。
2.4 工程里怎么算才不炸:对数阶乘与递推
真写代码时,我的默认做法是:凡是要参与乘法或除法的阶乘,一律先转对数。Python 标准库提供了math.lgamma,它返回 ln(Γ(x)),对应的就是 ln((x-1)!)。要算 log(n!),用math.lgamma(n+1)。
import math def log_factorial(n): return math.lgamma(n + 1) for n in [10, 100, 1000, 10**6]: print(n, log_factorial(n))100 万的阶乘,对数约 1.28×10⁷,一个普通的双精度数就能存。而如果直接算 100 万阶乘,天文数字级的位数根本没法处理。
另一种常见做法是递推累加。当你要连续算很多个不同 n 的对数阶乘时,维护一个累加变量比每次调 lgamma 更快:
def log_factorial_table(n_max): table = [0.0] * (n_max + 1) for i in range(1, n_max + 1): table[i] = table[i-1] + math.log(i) return table这个写法在自然语言处理的 n-gram 语言模型里很常用,因为困惑度计算需要反复取不同阶数的阶乘对数。一次建表,后续全部 O(1) 查询,比重复调库函数快一个量级。
注意:累加 log 会有累积浮点误差,n 到百万级时误差仍在可接受范围(相对误差 10⁻¹⁰ 量级),但如果要极高位精度,还是得分段求和或者用更高精度类型。
3. 阶数体系的实操拆解:比较、判定与陷阱
3.1 同阶、高阶、等价阶:判定的三条路
判断两个函数在某个趋近过程下的相对阶数,我常用的方法有三条,按优先级排。
第一条是直接求比值极限。lim f/g = c(非零常数)说明同阶;等于 0 说明 f 是 g 的低阶无穷小;等于 ∞ 说明 f 是高阶。这个方法最稳,只要极限存在就行。注意这里的“同阶”不要求比值等于 1,只要求是个非零常数。
第二条是泰勒展开对比首项。两个函数都展开,看最低次非零项的次数,次数相同就同阶,不同就按次数大小分高低。这个方法在处理复杂复合函数时特别顺手,因为展开后一目了然。
第三条是等价替换。如果 f ~ f₁ 且 g ~ g₁,那么在很多场景下可以用 f₁/g₁ 代替 f/g 求极限。这是最快的路子,但适用条件有严格限制,下一节专门说。
三条路各有适用面,我自己习惯是先用等价替换快速试探,再用比值极限确认,涉及多个函数相乘相加时回到泰勒展开看首项。这套组合拳打下来,九成以上的阶数判断都能一次搞定。
3.2 求极限时等价替换的适用边界
等价替换是很多人的“万能钥匙”,但它有明确的失效场景,这个坑我踩过不止一次。
失效场景一:加减法里直接替换。x→0 时 tan x - sin x,如果分别替换成 x 和 x,得到 0,结论是“极限为 0”。但实际上 tan x - sin x ~ x³/2,极限除以 x³ 是 1/2。替换后首项被消掉了,信息全丢。正确做法是展开到第一个不抵消的阶。
失效场景二:复合函数内层替换后外层不成立。比如 ln(1 + sin x),x→0 时 sin x ~ x,所以 ln(1+sin x) ~ ln(1+x) ~ x,这个是对的,因为 ln 在 1 附近连续可导。但如果外层函数在替换点附近不连续或者导数有问题,就不能这么干。
失效场景三:多变量情形下的路径依赖。二元函数沿不同路径趋近原点,阶数可能不同。这时候“同阶”这个概念本身就要小心,需要先确认是沿着哪个方向比较。
我的实操建议很朴素:加减法不做等价替换,乘除法随便替换。这条规则简单粗暴,但能挡掉八成错误。
3.3 多变量下的导数阶与曲率
单变量的二阶导是曲率,多变量时这个概念推广成 Hessian 矩阵。它的每个元素是 ∂²f/∂xᵢ∂xⱼ,规模是 d×d,d 是参数维度。这一步的阶数变化非常关键:梯度是 O(d),Hessian 是 O(d²)。模型有 100 万参数时,梯度存 100 万个数,Hessian 要存 1 万亿个数,按 float32 算是 4TB。这就是为什么二阶方法在大模型上基本用不了,只能走拟牛顿或者对角近似。
实际工程里,对角 Hessian 近似(比如 Adam 用的二阶矩估计)是一个折中方案:它只保留对角元素,规模回到 O(d),同时捕捉到了每个维度自己的曲率信息。这个设计的巧妙之处在于,它用极小的额外开销换到了自适应步长的效果,这也是 Adam 类优化器能广泛流行的原因之一。
从阶数角度看,还有一个容易忽略的点:Hessian 的条件数。条件数是最大特征值除以最小特征值,量级差异大意味着优化地形是狭长的峡谷,梯度下降会在里面来回震荡。这跟“阶数”的关系在于,条件数量级本质上反映了不同方向上的曲率阶数差异巨大,一阶方法对此无能为力。
3.4 复杂度阶数的估算方法
估算一个模块的计算量阶数,我有一套固定流程,用了很多年。
第一步,数清循环层数和每层的规模。单层循环遍历 n 个元素是 O(n);两层嵌套且两层都跑满 n 是 O(n²);三层是 O(n³)。这是最基本的。
第二步,看张量形状怎么变。全连接层输入 d、输出 d,权重是 d×d,一次前向是 O(d²) 乘加。注意力机制里 QK^T 是 (n×d)×(d×n) 得到 n×n,所以是 O(n²d)。这一项对序列长度是平方的,这就是长文本推理成本高的根源。
第三步,把所有项加起来取最高阶。O(n²d) + O(nd²),当 n 和 d 同量级时就是 O(n³),当 n 远大于 d 时以 n²d 为主。取最高阶这一步不能省,很多优化就是在换哪个项成为主导。
第四步,实测校验。理论阶数只是指导,实际还有常数因子、内存带宽、算子融合的影响。我习惯取 n = 128、256、512、1024 四个点跑一遍,看耗时比是不是接近理论阶数的比值。如果理论是 n²,那 n 翻倍耗时应该接近四倍;实测如果只有两倍,说明瓶颈在别处(比如被内存读写卡住了)。
这个流程的价值在于“先算后写”。我见过太多人在小规模上跑得挺欢,一上真实数据就发现跑不完,回头看才发现是某个模块的阶数估错了。
4. 把结论落到工程上:四个可直接复用的场景
4.1 参数量与计算量的阶数估算
给一张我自己常用的估算表,假设宽度(隐藏维度)为 d、层数为 L、序列长度为 n、词表大小为 V。
| 模块 | 参数量阶数 | 单次前向计算量阶数 | 关键说明 |
|---|---|---|---|
| 词嵌入 | O(Vd) | O(d) | 查表,计算量极小 |
| 单层全连接 | O(d²) | O(d²) | 权重矩阵主导 |
| L 层堆叠 | O(Ld²) | O(Ld²) | 与层数线性 |
| 自注意力 | O(4d²) | O(n²d + nd²) | 序列长时 n² 项主导 |
| 输出头 | O(Vd) | O(Vd) | 大词表时不可忽视 |
| 卷积(k×k 核) | O(k²·c_in·c_out) | O(k²·c_in·c_out·H·W) | 空间尺寸影响巨大 |
这张表能直接指导决策。比如词表是 5 万、d 是 1024,那嵌入层参数量是 5×10⁷ 量级,输出头同样大,两个加起来可能占到小模型的一半以上。想压参数量,先动这两个地方,而不是去砍中间层的宽度。
再比如注意力,n 从 512 涨到 4096 时,n² 项涨了 64 倍,而 nd² 项只涨了 8 倍。这就解释了为什么长上下文场景必须做稀疏化或者线性近似,纯靠堆硬件是扛不住的。
4.2 用泰勒阶数决定近似深度
把 exp 换成多项式近似,阶数选几?这里给出一个可以照抄的流程。
第一步,确定输入范围。softmax 里的指数输入是 logits 减去最大值,理论范围是 (-∞, 0],但实际绝大部分落在 [-20, 0]。如果只关心主力区间,可以按 [-10, 0] 设计。
第二步,估算所需阶数。用拉格朗日余项 |R_n(x)| ≤ M·|x|^(n+1)/(n+1)!,其中 M 是区间内 |f^(n+1)| 的上界。对 e^x 来说 M = e^0 = 1(在负区间上)。要 |R_n| < 10⁻⁴ 且 |x| ≤ 10,试 n=10 时,10¹¹/11! ≈ 10¹¹/3.99×10⁷ ≈ 2506,远远不够;n=20 时 10²¹/21! ≈ 10²¹/5.1×10¹⁹ ≈ 19.6,还是超;得 n 到 30 左右才压得住。
这个结论很反直觉——在宽区间上做多项式近似,阶数需求远超想象。所以工业界的做法不是硬堆阶数,而是先做区间缩减:利用 e^x = (e^(x/2))² 或者查表加小范围近似。这个技巧在执行时非常关键,我第一次做这个估算时也被 30 阶这个数字惊到了。
第三步,实测误差分布。写代码把近似值和精确值在区间内取 1000 个点对比,看最大相对误差和平均误差。理论界限是保守的,实测往往好不少,但不能用实测好来放松理论界限,因为测试点没覆盖到的地方可能恰好是最差的。
import math def taylor_exp(x, n): s, term = 0.0, 1.0 for k in range(n + 1): s += term term *= x / (k + 1) return s for n in [5, 10, 20, 30]: worst = max(abs(taylor_exp(x/10, n) - math.exp(x/10)) / math.exp(x/10) for x in range(-100, 1)) print(n, f"{worst:.3e}")这段代码把区间缩到 [-10, 0],看不同阶数的最坏相对误差。跑出来的曲线会清楚告诉你阶数和精度之间的兑换比例,比拍脑袋定阶数靠谱得多。
4.3 组合爆炸:排列、子集与搜索空间
设计数据增强或者搜索策略时,方案数经常是阶乘级或指数级,先算一遍能省下大量时间。
排列数是 n!,组合数是 C(n,k) = n!/(k!(n-k)!),子集总数是 2^n。这三者的增长速度差别很大。n=20 时,排列约 2.4×10¹⁸,子集约 10⁶,组合数在 k=10 时约 1.8×10⁵。也就是说,同样是 20 个元素,全排列比全部子集多了十二个数量级。
这个差异直接决定了策略选择。如果方案空间是子集级(10⁶),那可以暴力搜索或者做完整评估;如果是组合级(10⁵),可以枚举加剪枝;如果是排列级,那必须上启发式或者采样。
我在一个特征选择的小项目里就吃过这个亏:一开始想“试遍所有特征组合”,20 个特征时是 10⁶ 种,每个组合训练一次要 0.1 秒,总共 28 小时,还能接受;但特征加到 30 个时变成 10⁹ 种,时间直接涨到三年。正确的做法是换成贪心前向选择或者随机采样,用可控的预算换近似最优。
提示:算方案数时一定要用对数形式比较,直接算 30! 会溢出,算 ln(30!) 大约 74.66,一比较就知道和其他量级差多远。
4.4 数值稳定性的实操配方
阶乘和概率公式搅在一起时,稳定性问题几乎必然出现。我整理了几个固定配方。
配方一:概率连乘转对数求和。似然函数是 ∏ p_i,直接连乘很快下溢成 0。取对数变成 Σ ln p_i,数值范围立刻正常。
配方二:log-sum-exp。要在对数域里做加法,用 ln(Σ e^(x_i)) = m + ln(Σ e^(x_i - m)),m 是最大值。这一步把指数控制在 0 以下,避免上溢。
配方三:阶乘用 lgamma。出现在分母的阶乘,取对数后变成减法。比如泊松分布的对数概率:ln P(k) = k ln λ - λ - ln(k!) = k ln λ - λ - lgamma(k+1)。
配方四:小量用比值代替绝对量。当你在比较两个极小的概率时,直接比它们的比值(对数域里是相减),避免两个都下溢成 0 后无法区分。
这四个配方配合使用,能覆盖我遇到过的绝大多数稳定性问题。它们的共同思路是“把乘法搬到对数域、把加法搬到最大值附近”,理解了这一点,遇到新情况也能自己推。
5. 常见问题与排查实录
5.1 溢出与精度问题速查
实际排查时,下面这几种症状对应的问题我见得最多。
症状一:损失突然变成 nan 或 inf。八成是某处算了大数相乘或者对 0 取了对数。排查顺序是先看概率计算有没有连乘,再看有没有在分母上放接近 0 的量。阶乘出现在分母时,如果没取对数,n 稍大就会让整个分式下溢成 0。
症状二:结果在某个阈值前后跳变。典型原因是整数溢出。用 int32 存中间结果,n 超过 12 就翻车;用 float32 存大数,超过 3.4×10³⁸ 变成 inf。把中间变量改成 int64 或者 float64 通常能立刻定位。
症状三:近似函数在某些输入上误差突然放大。先检查输入是否超出了设计区间,再检查阶数是否足够。泰勒近似超出收敛半径后误差会爆炸式增长,这个不是“精度不够”,而是“方法失效”。
症状四:两个概率比较大小结果反了。这是典型的精度损失。两个 10⁻³⁰ 量级的数在 float64 下还能区分,但到 10⁻³¹⁰ 就已经是 0 了。改成比较对数比值。
5.2 阶数判断错误的典型症状
阶数判断错了,往往不会立刻报错,而是表现为“性能莫名不达标”。我总结了几种表现。
表现一:小数据飞快,大数据卡死。如果实际是 O(n²) 但你按 O(n) 规划容量,数据量翻十倍时耗时涨一百倍,远超预期。定位方法是二分法找耗时突变的规模点,或者直接采样测耗时拟合幂次。
表现二:加机器不解决问题。如果你的模块是 O(n²) 且瓶颈在单卡内存,加卡只能分摊不能降低阶数,规模一上去照样爆。这时候必须换算法,而不是加资源。
表现三:理论上该快的优化没效果。比如你优化了某个 O(nd) 的项,但整体被 O(n²d) 的项主导,那优化完全白做。先做阶数分解,找到主导项再动手。
判断方法其实很简单:取四个规模点测耗时,看比值。规模翻倍耗时翻两倍是线性,翻四倍是平方,翻八倍是立方。这个方法五分钟能做完,比看代码猜靠谱得多。
5.3 一张速查表
| 问题现象 | 可能原因 | 排查动作 |
|---|---|---|
| 阶乘结果变成 0 或 inf | 未取对数、位宽不够 | 改 lgamma,中间量升到 64 位 |
| 概率连乘后全为 0 | 下溢 | 转对数域求和 |
| 泰勒近似远处误差大 | 超出收敛半径 | 缩区间或换展开点 |
| 注意力耗时随长度暴涨 | O(n²) 项主导 | 用分块或稀疏化 |
| 参数扩宽后显存翻数倍 | 参数量 O(d²) | 先算 d² 再决定宽度 |
| 组合搜索跑不完 | 方案数阶乘级 | 换采样或贪心 |
| 两概率比较异常 | 精度不足 | 比较对数比值 |
| 理论加速比没达到 | 主导项判断错 | 分解各阶数项分别测 |
这张表我贴在自己的备忘录里,遇到新问题先扫一遍,能解决大半。剩下那些真正新的,解决完也补进表里,两年下来基本上覆盖了日常场景。
5.4 几个不太容易想到的注意点
最后补几个零散但实用的经验。
第一,大 O 记号丢掉的常数在中小规模下可能反超。O(n log n) 的算法在 n 小于几百时经常跑不过 O(n²) 的朴素实现,因为常数因子差太多。做优化前先确认规模区间,别一上来就上复杂算法。
第二,对数阶乘的减法会损失精度。当两个对数值都很大且接近时,相减会大量丢有效位。典型场景是小概率事件的对数概率差。这时候要么用更高精度类型,要么换用比值形式重写公式。
第三,阶数的结论依赖趋近过程。同一个函数,x→0 和 x→∞ 的阶数关系可能完全相反。写文档时一定要标明趋近方向,不然接手的人会按错的假设去优化。
第四,高维下“阶数”要按每个维度分别看。一个函数对 x 是二次的、对 y 是一次的时候,笼统说“它是二次的”没有意义。实践中要分别估算每个维度的敏感度,再决定在哪个方向上做近似。
第五,实测拟合的幂次常常不是整数。因为真实系统里多种阶数的项混在一起,加上缓存和并行的干扰,拟合出来的指数可能是 1.6 或者 2.3。这时候不要强行归类,看清楚是在哪个区间做什么事,比贴标签重要得多。
我个人在这一章内容上的体会是,阶乘和阶数这类看起来偏基础的东西,价值不在公式本身,而在它们提供的“预判能力”。写代码之前先在纸上估一遍量级,很多坑根本不会踩;出了问题再回头算阶数,往往三分钟就能定位到方向。这个习惯养成之后,调参和优化的效率提升比换任何工具都明显。