泰勒展开:用多项式逼近复杂函数,只用加减乘除

泰勒展开:用多项式逼近复杂函数,只用加减乘除
泰勒展开是那种第一眼特别劝退的数学工具公式里有导数、有阶乘、有求和符号还没开始学先被符号吓住了。但如果你把它一项一项写开会发现计算过程到最后只剩加减乘除也就是小学二年级数学课上学过的那几样运算。标题就是这个意思——泰勒展开不是天外飞仙它的本质是“用多项式去逼近一个复杂函数”而多项式在计算机里只需要乘法和加法就能算。下面我从三个角度把它讲透先说明泰勒展开到底在解决什么问题再用 Python 把它亲手实现一遍看精度怎么一步步提高最后聊聊误差边界以及在优化、数值计算和工程近似里它为什么无处不在。适合正在学微积分但还没把原理串起来的同学也适合写算法时看着 math 库里的 sin、exp 想知道背后逻辑的工程师。1. 先把这个标题翻译成一句人话1.1 泰勒展开到底在做什么一个复杂函数 f(x)比如 sin(x)、e^x、ln(x)在你关心的某个点 a 附近可以用一个多项式来近似表示f(x) ≈ f(a) f(a)(x-a) f(a)(x-a)^2/2! f(a)(x-a)^3/3! ...当 a 0 时这个式子叫麦克劳林展开是泰勒展开最常见的特例。右边的核心特点只有一个它是一个多项式。多项式长什么样就是一堆系数乘以 x 的幂次然后加起来。这种函数在数学库里是最容易处理的求值只需要乘法和加法求导有非常固定的规则比较大小也直观。为什么要干这件事因为 sin、exp、log 这类函数在 CPU 指令集层面并没有真正的“sin 单元”或“log 单元”。硬件擅长的是加减乘除和访存剩下的事要靠软件逼近。如果你能把 sin(x) 在某个范围里写成一个多项式那么算 sin(x) 就变成了几十次乘法和加法速度立刻不一样。泰勒展开就是这座桥把“复杂函数”变成“计算机擅长处理的多项式”。很多教程会把重点放在推导和证明上但实际工程里最重要的就是你记住这个替换关系——复杂函数可以局部替换成多项式而多项式只需要四则运算就能算。1.2 为什么说它只用了小学二年级的运算展开式里确实有 x³、3! 这类看起来很唬人的东西。但你拆开看x³ 就是 x * x * x三次连乘。3! 就是 3 * 2 * 1。负号就是乘 -1。整个多项式求和就是反复相加。求多项式值最常用的方式叫霍纳方法中文教材里也常叫秦九韶算法。它的核心想法很简单不要先算 x 的各个幂次再乘系数而是把多项式重新组织成一层套一层的括号a0 a1*x a2*x^2 ... an*x^n ((...(an*x a_{n-1})*x ... a1)*x a0每一步只有一次乘法和一次加法def horner(coeffs, x): # coeffs 按高次到低次排列例如 [1, 0, -1/6, 0, 1/120] res 0.0 for c in coeffs: res res * x c return res所以标题说“小学二年级就学过”是有道理的展开式的数值求值部分确实只有加减乘除。但必须把话说清楚这不代表小学生真的能推导泰勒展开。那些系数是怎么来的靠的是导数导数是微积分的内容。标题强调的是“展开之后算多项式”这一层只需要四则运算至于“怎么求出这些系数”那才是真正需要学的地方。把这两个阶段分开思路就会清晰很多先求系数再算多项式。求系数是数学问题算多项式是算术问题。2. 拆开公式每一项都不是黑魔法2.1 导数、阶乘、幂次分别管什么事泰勒展开的每一项都有明确分工不是符号堆砌。先看 (x-a)^k。它描述的是“离展开点 a 的距离”。k 0 时这项是常数k 越大(x-a) 的幂次越高离 a 越远时这项影响越大。所以整个展开式天然有一个特点在 a 附近最准离得越远误差越难控制。再看 f(a)、f(a) 这一串导数。它们携带的是函数在 a 点的变化信息。f(a) 告诉你起点在哪里f(a) 告诉你起点处斜率是多少f(a) 告诉你斜率变化得有多快也就是弯曲程度。展开式之所以能逼近原函数是因为它让多项式在 a 点处的 0 阶、1 阶、2 阶……导数值分别和原函数完全一致。最后是 k!。它为什么必须出现在分母可以这样推导设多项式里有一项 c_k (x-a)^k对它求 k 次导数会得到 c_k * k!。我们希望这一项在 a 点的 k 阶导数等于 f^(k)(a)于是c_k * k! f^(k)(a) c_k f^(k)(a) / k!阶乘不是天上掉下来的。它是 (x-a)^k 求导 k 次之后自然出现的系数需要在外面除一次才能把两边对齐。2.2 从 e^x 和 sin(x) 看逐阶逼近e^x 在 0 这个点有一个天然优势它的任意阶导数都是 e^x在 0 处都是 1。所以展开式非常干净e^x 1 x x^2/2! x^3/3! x^4/4! ...取 x 1左边就是 e 本身。每多保留一项部分和就更接近 e保留到的项部分和与 e 的误差第 0 项11.7182...第 1 项20.7182...第 2 项2.50.2182...第 3 项2.6666...0.0516...第 4 项2.7083...0.0099...第 5 项2.7166...0.0016...第 10 项2.7182818...约 2.7e-8这个表比任何文字都有说服力。每次加一项误差按数量级往下掉。再看 sin(x)。它在 0 处的偶数阶导数是 0奇数阶导数交替为 1 和 -1所以展开式只剩奇数项sin(x) ≈ x - x^3/3! x^5/5! - x^7/7! ...这里有一个很自然的物理直觉如果只用第一项 sin(x) ≈ x这就是高中物理里“小角度单摆”近似多保留几项精度就会快速提升。所谓“逐阶逼近”就是你每多对齐一阶导数多项式和原函数就多了一层相似性。3. 用 Python 亲手算一遍从错误写法到正确递推3.1 环境准备一个脚本文件就够这部分不需要 GPU不需要新框架。Python 3.8 以上一个临时目录一个 taylor_demo.py就够了。对比结果用标准库 math如果要做符号验证再装一个 sympy。我一般建议先建一个临时目录不要为了测试去搭完整环境mkdir taylor_demo cd taylor_demo python --version确认能跑 Python 之后直接新建脚本。第一次跑通之后再想批量对比和画误差曲线的事。3.2 关键写法用递推不要每次重新算阶乘新手最容易犯的错是在循环里每次重新算 x 的幂次和阶乘# 不推荐每次循环都重新算阶乘和幂次 for k in range(n): term x ** k / math.factorial(k) total term这个写法在 n 比较小时能出结果但有两个问题第一浪费。幂次和阶乘可以从前一项递推出来没必要每次从头算。第二会溢出。170! 大约 7.26e306已经逼近 double 型能表示的上限 1.8e308171! 直接超过 double 范围。只要 n 到 171程序就会出问题。正确做法是用递推关系让新的一项从旧的一项推出来。对 e^xterm_0 1 term_{k1} term_k * x / (k1)对 sin(x)term_0 x term_{k1} -term_k * x^2 / ((2k2)(2k3))看代码import math def taylor_exp(x, n20): 在 x0 处展开 e^x返回前 n 项部分和 total 0.0 term 1.0 for k in range(n): total term term term * x / (k 1) return total def taylor_sin(x, n10): 在 x0 处展开 sin(x)返回前 n 项部分和 total 0.0 term x for k in range(n): total term term -term * x * x / ((2 * k 2) * (2 * k 3)) return total if __name__ __main__: print(taylor_exp(1.0, 20) , taylor_exp(1.0, 20)) print(math.exp(1.0) , math.exp(1.0)) print() print(taylor_sin(0.5, 10) , taylor_sin(0.5, 10)) print(math.sin(0.5) , math.sin(0.5))sin(x) 那两行递推需要仔细看符号每次翻转所以乘 -1x 的幂次从 2k1 变成 2k3所以乘 x²分母从 (2k1)! 变成 (2k3)!所以除以 (2k2) 和 (2k3)。正常输出会是这样taylor_exp(1.0, 20) 2.7182818284590455 math.exp(1.0) 2.718281828459045 taylor_sin(0.5, 10) 0.479425538604203 math.sin(0.5) 0.479425538604203到这里你已经亲手实现了两个常用函数的泰勒展开并且精度和标准库几乎一致。3.3 验证逐项打印、单点对比、换 x 再测只看最后一位对不对不足以说明实现可靠。我建议按三步验证。第一步逐项打印中间结果确认每一项的大小确实在下降def taylor_exp_debug(x, n8): total 0.0 term 1.0 for k in range(n): total term print(fk{k} term{term:.8f} partial_sum{total:.8f}) term term * x / (k 1) return total输出大致是k0 term1.00000000 partial_sum1.00000000 k1 term1.00000000 partial_sum2.00000000 k2 term0.50000000 partial_sum2.50000000 k3 term0.16666667 partial_sum2.66666667 k4 term0.04166667 partial_sum2.70833333 ...项在减小说明递推方向对。如果哪一项突然变大或者符号规律乱了第一件事就是检查递推公式的分子分母有没有写反。第二步单点对比 math 库。不要把 n 拉满先对比有限项下的误差是否在预期范围。第三步换不同的 x 再测。x 取 0.1、0.5、1、2不要只测一个点。你会发现 x 越接近展开点收敛越快x 越大同样的 n 误差越大。这是泰勒展开最基础的直觉。如果想验证系数本身可以用 sympyimport sympy as sp x sp.symbols(x) print(sp.series(sp.sin(x), x, 0, 10)) # 输出: x - x**3/6 x**5/120 - x**7/5040 x**9/362880 O(x**10)sympy 适合用来对系数但不适合用来理解原理。原理还是得靠手写循环。注意如果算出来的值和 math 库差距很大按这个顺序排查——先看 x 的绝对值是不是太大再看展开点是否在定义域内然后看项数是不是真的够最后看递推公式的分子分母是不是写反了。4. 误差从哪来什么时候会翻车4.1 余项公式误差不是玄学泰勒展开不是等式而是近似。截断到第 n 项之后剩下的误差用一个余项来表示最常见的拉格朗日余项长这样R_n(x) f^(n1)(ξ) / (n1)! * (x-a)^(n1)其中 ξ 是 a 和 x 之间的某个点。这个公式看着抽象但信息量很大误差由三件事决定。一是 f^(n1)(ξ)也就是下一阶导数有多大。如果函数在 a 和 x 之间变化剧烈误差天然就大。二是 (x-a)^(n1)。如果 x 离 a 很近这个因子很小加阶数会让误差按指数下降如果 x 离 a 很远这个因子反而会把误差放大。三是 (n1)!。它随 n 增长极快所以理论上阶数越高误差越小。余项公式告诉我们一个工程判断方法判断误差不要只看阶数还要看 |x-a| 和下一阶导数的量级。4.2 收敛半径在半径内加阶数才有意义不是所有函数在任何点展开都能无限逼近。典型例子是1/(1-x) 1 x x^2 x^3 ...等式右边只在 |x| 1 时收敛。x 1 时左边无定义右边直接发散x 2 时右边各项越来越大加多少项都没用。这就是收敛半径。ln(1x) 在 0 处展开ln(1x) x - x^2/2 x^3/3 - x^4/4 ...收敛半径也是 1。而且端点要单独讨论x -1 时 ln 没有定义x 1 时级数恰好收敛到 ln 2。如果 x 超出了当前展开点的收敛半径有两种解法。一是换展开点比如 ln(x) 在 0 处没法展开但在 1 处可以二是做变量替换把大数映射到小区间这在浮点计算里尤其常见。对初学者来说最重要的不是背诵各种收敛半径的判断技巧而是建立“泰勒展开只在展开点附近有效”这个直觉。看到大参数先别急着加阶数要想想它离展开点有多远。4.3 浮点数下的三个坑数学上收敛不代表浮点数下算得准。这里有三类常见坑。第一类直接算大阶乘会溢出。如前面说的double 最多表示到约 1.8e308171! 就超了。所以工程实现里几乎都用递推而不是每次重新算阶乘。第二类大参数下正负项相消。sin(x) 的泰勒展开符号交替如果 x 很大比如 50每一项的绝对值都很大但最后结果只有 -0.26 左右。浮点数的有效位数只有 16 位左右两个大数相减真实信息会从低位丢光。解决办法是做参数压缩先把 50 对 2π 取模映射到 [-π/2, π/2] 这个小区间再展开。def sin_safe(x, n10): # 先把 x 折回 [-pi, pi]再调用泰勒展开 x x % (2 * math.pi) if x math.pi: x - 2 * math.pi if x -math.pi: x 2 * math.pi return taylor_sin(x, n)第三类大正数下小项被大和吃掉。e^x 在 x 100 时前面若干项非常大后面才逐渐变小。如果把小项逐个加到已经很大的部分和上低位数字直接丢失。工程上一般拆成整数次幂乘小数次幂例如 e^100 e^64 * e^36对小数部分用展开式。这三类问题不是泰勒展开本身的问题而是“浮点表示”和“数学级数”之间的差异。写代码时要同时考虑两层。4.4 不是阶数越高就一定越好理论上 n 越大误差越小但浮点环境下高阶项可能反而把舍入误差放大。我自己测试时有个习惯对同一个 x分别用 n 5、10、20、50 跑一遍把误差打出来看。你会发现一开始误差快速下降然后变平甚至在高阶时略微反弹。这项练习能帮你建立“够用就好”的感觉。另外一个相关现象是多项式逼近里的 Runge 现象用高阶多项式插值时区间端点附近经常出现大幅震荡。泰勒展开不是插值但它同样提醒你多项式逼近在展开点附近最可靠离得远就有风险。所以工程上通常是分段逼近把大范围切成小段每段用低阶多项式而不是在一个展开点上堆无限高阶。5. 工程和算法里泰勒展开到底在哪儿5.1 数学库里的 sin、exp 背后是多项式逼近标准数学库里的 sin、exp、log 具体实现通常不是直接截断泰勒级数而是先做参数压缩再在小区间上用多项式近似。常用的已经不是原始泰勒级数而是 Remez 算法这类“极小极大”方法让误差在区间内尽量均匀分布。但思路同源把复杂函数转换成多项式求值。如果你在代码里看到一堆来历不明的小数系数不用惊讶那大概率就是某个多项式逼近的系数表。学习时从泰勒展开入手最顺因为它给了你一个理解一切多项式近似的起点。5.2 优化算法里的一阶展开和二阶展开在机器学习里梯度下降可以看作一阶泰勒展开的直接应用。在优化点 x 附近把损失函数展开f(x Δ) ≈ f(x) f(x) * Δ要让 f 下降沿着负梯度方向走一步就得到梯度下降更新x_{k1} x_k - η * f(x_k)η 是学习率。为什么学习率太大容易震荡因为一阶泰勒展开只在局部成立步子迈大了Δ 超出展开有效的范围近似就失效了。这个直觉来自泰勒展开而不是来自某个具体框架。如果把展开做到二阶f(x Δ) ≈ f(x) f(x)Δ 1/2 * f(x) * Δ^2对 Δ 求导并令其为 0就得到一维形式的牛顿法Δ -f(x) / f(x)这就是二阶信息的使用。深度学习里的自适应优化器虽然实现方式更工程化很多也是在近似地估计二阶信息。理解了一阶、二阶泰勒展开再去看这些算法会清晰很多。5.3 工程近似中的线性化工程里的“线性化”几乎都长着泰勒展开的脸。单摆在小角度下用 sin θ ≈ θ这是把 sin 在 0 处展开后只保留一阶项。电路里二极管、三极管在工作点附近的小信号模型本质是把非线性关系在静态工作点做一阶展开。控制系统里的灵敏度分析、误差传递也经常依赖一阶偏导。学完泰勒展开再回头看这些公式会发现它们不是孤立的知识点而是同一个思想在不同场景下的投影复杂的非线性关系在局部用简单的多项式去替代够用就行。6. 想真正掌握建议按这个顺序练6.1 先把六个常用展开式背下来函数在 0 处的展开收敛半径e^x1 x x²/2! x³/3! ...全体实数sin xx - x³/3! x⁵/5! - ...全体实数cos x1 - x²/2! x⁴/4! - ...全体实数1/(1-x)1 x x² x³ ...ln(1x)x - x²/2 x³/3 - ...(1x)^α1 αx α(α-1)/2! x² ...注意收敛半径无穷大只代表数学级数收敛不代表浮点数下一定算得准。e^x 在 x 1000 时虽然级数收敛但直接用浮点展开会有精度问题。数学收敛和数值稳定是两件事。6.2 每个练习都要做的四步自查每次做完一个展开按这个顺序过一遍确认展开点和定义域。ln(x) 不能在 0 处展开√x 也不能在小于 0 的区域展开。确认 |x - a| 是否落在收敛半径内。半径以外加阶数没有意义。确认需要的阶数。如果要求误差到 1e-6需要几项可以先估算余项再实测验证。和 math 库或 sympy 对照。不要只看一个点至少换三个 x 测一遍。这四步做完才算真的把一个展开式吃透。6.3 三个值得记住的实战建议第一个建议先用小参数验证。x 从 0.1、0.5 开始不要一上来就跑 x 100。小参数下误差容易控制也方便你确认实现逻辑是否正确。第二个建议不要盲目追求高阶数。对同一个点把 n 5、10、20、50 的误差都打出来看曲线从什么时候开始变平甚至变差。你会在实际数据里体会到“够用就好”的分寸。第三个建议工程里需要多项式逼近时优先用成熟工具。numpy、sympy、scipy 以及硬件厂商的数学库都比你手写可靠。自己手写泰勒级数适合学习、快速原型以及范围受控且精度要求不高的场景。自己写可以但上线前要评估范围和误差别让“看起来对”骗了你。回到标题那句话。泰勒展开确实不是什么高不可攀的东西它的数值计算部分就是小学二年级的加减乘除复杂的只是背后的微积分逻辑。先把 e^x 手算到第 5 项再用 Python 把小参数下的误差打出来很多恐惧感会自然消失。真正踩过几次坑之后你会发现多数问题不是泰勒展开太难而是你还没把一个具体函数在具体点的展开真刀真枪地写出来过。

最新新闻

日新闻

周新闻

月新闻