🏠 总目录📚 本教程 09 · log 域运算 ← →
📑 本页目录(点开跳转)

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)))
浮点范围与精度
浮点类型maxlog(max)epstiny
float641.7976931348623157e+308709.782.220e-162.225e-308
float323.4028234663852886e+3888.721.192e-071.175e-38
float1665504.011.099.766e-046.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)
小量的对数:逐行比较
xlog(1+x)log1p(x)相对误差
1e-1609.9999999999999998e-171.00e+00
1e-101.000000082690371e-109.9999999995000007e-118.27e-08
1e-089.9999998892252911e-099.999999950000001e-096.08e-09
1e-059.9999500003988414e-069.9999500003333302e-066.55e-12
小量的指数:逐行比较
xexp(x)-1expm1(x)相对误差
1e-1609.9999999999999998e-171.00e+00
1e-101.000000082740371e-101.00000000005e-108.27e-08
1e-089.9999999392252903e-091.0000000049999999e-081.11e-08
1e-051.0000050000069649e-051.0000050000166668e-059.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

因果链

nan 的检测 np.isnan([nan, 1.0]) = [ True False]
inf 的检测 np.isfinite([inf, 1.0]) = [False True]
nan == nan -> False 所以只能用 isnan

四种浮点错误,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 做长尾变换。⭐ 本章第四节说的是同一个函数的另一个用途(小量的有效数字),两个用途不冲突,一行代码同时享受

✅ 检查点

  1. np.logaddexp(800, 801) 内部算的是哪个闭式?为什么那个式子永远不会溢出?
  2. NumPy 里有 logsumexp 这个函数吗?不装 scipy 的话该写什么?
  3. 2000 个 1e-4 到 1e-3 之间的概率连乘,np.prod 给什么?乘到第几个就出问题了?log 域给的是多少?
  4. float64 / float32 / float16 的 exp 临界指数各是多少?这三个数怎么查出来的?
  5. np.exp(90) 是 inf 吗?
  6. log(1 + 1e-16) 为什么是 0?这是 log 的问题吗?
  7. log(1+x) 在 x=1e-16 和 x=1e-10 两种情况下,哪一种更危险?为什么?
  8. log(1 + exp(x)) 的正确写法是什么?
  9. naive BCE 在 z=30, y=0 上给出 30.00102056 而正确答案是 30.0 —— 它报错了吗?成因是什么?
  10. np.errstate 的四种错误里,哪一种通常不该开 raise?为什么?
  11. np.errstate(all="raise") 能不能兜住整数溢出?
👀 答案
  1. max(a,b) + log(1 + exp(−|a−b|)),实测和 np.logaddexp 逐位相同(都是 801.3132616875182)。永不溢出是因为把 max 提出来之后,剩下的指数 −|a−b| 一定 ≤ 0,exp 的结果落在 (0, 1]。两个 exp(800) 一个都没算。
  2. 没有(scipy.special 里才有)。不装 scipy 就写 np.logaddexp.reduce(z) ——实测和手写的 m + log(sum(exp(z-m))) 给出完全相同的 802.4076059644444。
  3. np.prod 给 0.0。💀 乘到第 99 个就变成 0 了(那时累积 log 值是 −751.01,已越过 −745 那道下溢线)。log 域给 −15314.752401524813,好端端地待着。
  4. 709.78 / 88.72 / 11.09。用 np.log(np.finfo(dtype).max) 查——exp 的临界指数就是「该 dtype 最大值的对数」。实测最后一个不炸的整数分别是 709 / 88 / 11。
  5. float64 下不是:np.exp(90) = 1.2204032943178408e+39,一个有限的正常数字。⚠️ 但 np.exp(np.float32(90)) 是 inf。「多大算大」没有统一答案,只有 per-dtype 的答案。
  6. 因为 float64 的 eps 是 2.22e-16,1 + 1e-16 就等于 1(实测 True)。不是 log 的问题——那个 x 在做加法的那一瞬间就被扔掉了。
  7. x=1e-10 更危险。1e-16 那种结果直接是 0,相对误差 100%,太扎眼一定会被发现;1e-10 给的是 1.000000082690371e-10,看起来完全正常,只是悄悄丢了 7 位有效数字(相对误差 8.3e-8),会一路传到下游变成「指标差 0.3% 查不出原因」。
  8. np.logaddexp(0, x)。它就是 softplus 的标准写法。
  9. ⚠️ 没报错,也没出 inf,数量级还是对的。成因:p = 1/(1+exp(-30)) 约等于 1 − 9.36e-14,算 1 - p 时前 13 位全被抵消——和第四节 log1p 那个坑是同一种病,只是换了身份。正确写法 np.logaddexp(0, -z) + (1-y)*z 给 30.0。
  10. under(下溢)通常不该开。归一化之后大量小数下溢是正常现象,开了满地噪声。最该开的是 invalid ——nan 会污染下游所有计算。
  11. 不能。 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

打卡记录保存在你的浏览器里,首页能看到总进度