qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

每一帧最像空白,为什么整段最可能读出 a?

穷举九条 CTC 路径,分清逐帧贪心、重复合并与标签概率求和,并检查重复字符需要的额外帧。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先合并相邻重复,再去掉空白
  4. 把九条路径分别归到标签桶里
  5. 训练损失对齐所有路径,解码可以只是近似
  6. 前向递推到底合并了什么:只追踪目标 a
  7. 输入太短时,概率不是很小,而是零
  8. 先完整枚举,再核对递推
  9. 可选:用真实 CTCLoss 核对这六个目标
  10. 这里没有训练一个语音模型
  11. 记住这些要点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
CTC 路径折叠
先把连续相同的逐帧符号合并,再移走特殊空白,让较长的帧序列变成最终标签。
对路径求和
不同逐帧走法可能读成同一个词,要把这些走法的概率都算到这个词名下。
打开完整术语小抄 ↗

两帧输出里,空白的概率都是 0.5,a 都是 0.4,b 都是 0.1。逐帧选最大值,会得到两个空白,最后什么字符也没有。但把所有能读成 a 的路径加起来,a 的概率却是 0.56。

CTC 把逐帧序列映射成较短的标签序列,让训练时不必给每一帧人工标好对应字符。它的关键是许多路径可以得到同一个标签;最大路径与最大标签因此不是同一个问题。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。可选框架脚本需要已有 PyTorch;实测 Python 3.14.0 + PyTorch 2.9.1+cpu,CPU float64 单线程。基础脚本仍只需标准库。下方命令运行基础 Python 标准库示例;可选对照的环境和命令见对应 README。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 11 个文件
包里有什么
  • ctc_paths.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要
  • ctc_torch_cpu.py:可选框架对照配套文件
  • README-ctc-framework.md:可选框架对照配套文件
  • observed-ctc.stdout.txt:可选框架对照配套文件
  • ctc-torch-report.json:可选框架对照配套文件
  • framework-ctc-verification.json:可选框架对照配套文件
  • supplement-verification.json:附加实现的独立复跑记录

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B ctc_paths.py

解压到独立练习文件夹,在含 ctc_paths.py 的目录打开终端运行。基础为固定逐帧概率的标准库 CTC 路径枚举与前向递推;额外在 PyTorch 2.9.1+cpu 核对 2/3 帧、a/aa/空串的 CTCLoss 与 zero_infinity。

未训练声学模型、未计算梯度、未运行 GPU、beam search 或语言模型。 压缩包还附可选 PyTorch 对照;请按 README-*-framework.md 使用已安装 PyTorch 的解释器运行。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

把路径放进同一个标签桶

每帧使用相同的概率表。逐帧选最大,只得到一条路径;把能折叠成相同标签的全部路径加起来,才能比较标签的总概率。

每帧:blank「_」= 50%,a = 40%,b = 10%。逐帧同分时按 _、a、b 的次序选择。

贪心路径:_ → _,单条概率 0.2500;折叠后是「空串」。

总概率最大的标签:「a」,概率 0.5600。

这组参数下,最可能单条路径与最大标签总概率给出了不同结果。

a0.5600
空串0.2500
b0.1100
ab0.0400
ba0.0400
查看全部 9 条路径怎样归并
逐帧路径折叠标签路径概率
_ → _空串0.2500
_ → aa0.2000
_ → bb0.0500
a → _a0.2000
a → aa0.1600
a → bab0.0400
b → _b0.0500
b → aba0.0400
b → bb0.0100

这是固定短序列的概率枚举,没有语音、训练模型或真实解码器。先合并相邻重复,再删除 blank;因此 a → _ → a 变成 aa,a → a → _ 变成 a。页面用整数百分比相乘后再显示小数;长序列应采用稳定的动态规划,不能靠穷举扩展。

先合并相邻重复,再去掉空白

本例用下划线 _ 表示 CTC 的特殊 blank。它不是输出文本中的空格字符,也不是待预测标签的一员。给定路径,先把连续相同符号合并,再删除 blank,得到最终标签。

路径 [a,a,_] 先合并为 [a,_],再删除空白,结果是 a;路径 [a,_,a] 没有相邻重复,删除空白后结果是 aa。若先删空白再合并,后者会被错误地压成 a。

这一顺序解释了为什么连续重复字符需要分隔:目标 aa 至少需要 a,_ ,a 三帧。更一般地,最少帧数等于目标长度加上相邻相同字符对数;例如 aba 至少三帧,aaa 至少五帧。

把九条路径分别归到标签桶里

两帧、每帧三个符号,总共有 3²=9 条路径。脚本把一条路径的逐帧概率相乘,再按折叠结果相加。所有标签概率相加必须为 1,因为每条路径恰好进入一个桶。

读成 a 的路径有 a,a;a,_;_,a,概率分别是 0.16、0.20、0.20,所以总概率为 0.56。空串只有 _,_,概率 0.25。b 的总概率为 0.11,ab 与 ba 各为 0.04。

贪心路径 _,_ 的概率 0.25 确实高于任何一条单独的 a 路径;但 a 汇集了三条路径,标签总概率更高。于是“每帧都选最大”没有算错自己的目标,只是没有解决“哪个折叠标签概率最大”。

训练损失对齐所有路径,解码可以只是近似

给定目标标签,CTC 概率是所有折叠成该标签的路径概率之和;损失再对这个和取负对数。不能只保留最可能的一条对齐后取负对数,还继续称为相同的 CTC 目标。

路径数量随帧数指数增长,真实训练不能逐条枚举。前向动态规划把相同状态的前缀概率合并,累计 stay、前进一格和条件允许的跨两格转移。为了正确保留重复字符,跨两格时当前非空白符号不能与两格前的符号相同。

本文同时实现了朴素枚举和前向递推,对 0 到 4 帧的固定输入、长度 0 到 3 的 a/b 标签逐项核对,共 75 对结果。枚举负责当独立参照;递推展示怎样避免重复数完相同前缀。

前向递推到底合并了什么:只追踪目标 a

想得到目标 a,可以把进度分成三个状态:还没写出 a 的 blank、已经写出 a、写出 a 后的 blank。前后的 blank 虽然符号相同,却代表不同进度,不能合成同一个状态。状态保存的是“目前走到这里的所有合法路径概率之和”,不是某一条最佳路径。

沿用两帧、每帧 blank/a/b 概率为 0.5/0.4/0.1。第一帧后的三个状态分别为 [0.5,0.4,0]:可以还在前面的 blank,也可以直接写出 a;第一帧不可能已经写完 a 后又来到后面的 blank。b 不在目标 a 的合法路径中。

到第二帧,前面的 blank 只能从前面的 blank 留下,得到 0.5×0.5=0.25。a 状态既能从前面的 blank 进入,也能从 a 自身留下,所以是 (0.5+0.4)×0.4=0.36,合并了 _,a 和 a,a 两条路径。后面的 blank 可以从 a 或该 blank 自身进入,得到 (0.4+0)×0.5=0.20,对应 a,_。

结束时只加后两个状态:0.36+0.20=0.56。前面的 blank 所含 0.25 仍代表空串,不能加入目标 a 的概率。这样,递推给出的 0.56 与三条合法路径逐个相加完全一致,但不必把每一条长路径单独保存。

再看重复目标 aa:展开的进度是 blank、a、blank、a、blank。不能从第一个 a 直接跳到第二个 a,否则相邻的 a,a 折叠后只剩一个 a。必须经过中间 blank,才可能保留两个 a。这就是跨两格规则要检查“符号是否相同”的原因,不是为了让代码多加一个特殊分支。

输入太短时,概率不是很小,而是零

两帧无法形成 aa,因此目标概率恰好为零,负对数损失为正无穷。加入第三帧后,使用给定概率表,唯一能形成 aa 的路径 a,_ ,a 概率为 0.7×0.6×0.7=0.294。

PyTorch CTCLoss 文档说明了输入过短可能出现无穷损失,zero_infinity 可将无穷损失与相关梯度置零。但把值置零并没有创造一条合法对齐,也不说明该样本已经学会;仍需检查输入下采样、标签长度与重复字符。

不同接口的 blank 索引也可能不同。这里符号表索引 0 是 blank;PyTorch CTCLoss 默认 blank=0,TensorFlow ctc_greedy_decoder 的默认 blank 是最后一类。接接口时应明写参数,不能只搬运同一组数字。

先完整枚举,再核对递推

保存为 ctc_paths.py,运行 python -B ctc_paths.py。程序输出标签桶概率、两种折叠例子、重复字符的最少帧数及前向核对数量,并把明细写入 report.json。

本例用普通浮点概率,适合很短的序列;长序列连乘容易下溢,实际实现通常需要稳定的计算方式。本文没有实现 log-space 动态规划、beam search、外部语言模型或词表约束。

展开收起代码 · 小实验python · 96 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Enumerate tiny CTC alignments and independently check a forward recurrence."""
from collections import defaultdict
from itertools import product
import json
import math
from pathlib import Path

SYMBOLS = ('_', 'a', 'b')
BLANK = '_'


def collapse(path):
    merged = [symbol for i,symbol in enumerate(path) if i == 0 or symbol != path[i-1]]
    return ''.join(symbol for symbol in merged if symbol != BLANK)


def enumerate_labels(emissions):
    masses = defaultdict(float)
    for indices in product(range(len(SYMBOLS)), repeat=len(emissions)):
        path = tuple(SYMBOLS[i] for i in indices)
        mass = math.prod(emissions[t][i] for t,i in enumerate(indices))
        masses[collapse(path)] += mass
    return dict(masses)


def forward_probability(emissions, label):
    if any(ch not in SYMBOLS[1:] for ch in label):
        raise ValueError('Target must exclude blank and unknown symbols.')
    extended = [BLANK]
    for ch in label:
        extended.extend([ch, BLANK])
    if not emissions:
        return float(not label)
    prev = [0.0] * len(extended)
    prev[0] = emissions[0][0]
    if label:
        prev[1] = emissions[0][SYMBOLS.index(label[0])]
    for frame in emissions[1:]:
        current = [0.0] * len(extended)
        for s,symbol in enumerate(extended):
            incoming = prev[s]
            if s > 0:
                incoming += prev[s-1]
            if s > 1 and symbol != BLANK and symbol != extended[s-2]:
                incoming += prev[s-2]
            current[s] = incoming * frame[SYMBOLS.index(symbol)]
        prev = current
    return prev[-1] + (prev[-2] if label else 0.0)


def minimum_frames(label):
    return len(label) + sum(a == b for a,b in zip(label,label[1:]))


def main():
    emissions = [[0.5,0.4,0.1], [0.5,0.4,0.1]]
    assert all(math.isclose(sum(frame),1.0) for frame in emissions)
    masses = enumerate_labels(emissions)
    assert math.isclose(sum(masses.values()),1.0)
    greedy = tuple(SYMBOLS[max(range(3), key=lambda i: frame[i])] for frame in emissions)
    best = max(masses, key=masses.get)
    assert collapse(greedy) == '' and best == 'a'
    print('greedy frame path=' + ''.join(greedy) + '; collapsed=<empty>; path probability=0.250000')
    for label,mass in sorted(masses.items(), key=lambda pair:(-pair[1],pair[0])):
        print(f'label={label or "<empty>"}: summed probability={mass:.6f}')
    print('collapse a_a=' + collapse(('a','_','a')) + '; collapse aa_=' + collapse(('a','a','_')))
    assert collapse(('a','_','a')) == 'aa'
    assert collapse(('a','a','_')) == 'a'
    assert masses.get('aa',0.0) == 0.0
    repeated = [[0.2,0.7,0.1],[0.6,0.3,0.1],[0.2,0.7,0.1]]
    repeat_prob = forward_probability(repeated,'aa')
    assert math.isclose(repeat_prob,0.7*0.6*0.7,abs_tol=1e-12)
    print(f'target aa: minimum frames={minimum_frames("aa")}; T=2 probability=0.000000; T=3 probability={repeat_prob:.6f}')
    checked = 0
    for frames in [[],emissions[:1],emissions,repeated,repeated+[[0.3,0.2,0.5]]]:
        all_masses = enumerate_labels(frames)
        assert math.isclose(sum(all_masses.values()),1.0,abs_tol=1e-12)
        for length in range(4):
            for chars in product('ab',repeat=length):
                label = ''.join(chars)
                expected = all_masses.get(label,0.0)
                actual = forward_probability(frames,label)
                assert math.isclose(actual,expected,abs_tol=1e-12)
                if len(frames) < minimum_frames(label):
                    assert actual == 0.0
                checked += 1
    report = {'emissions':emissions,'greedy_path':greedy,'greedy_label':collapse(greedy),
        'label_probabilities':masses,'best_label':best,'repeated_label_T3':repeat_prob,
        'forward_enumeration_pairs_checked':checked,
        'scope':'Specified frame probabilities; no acoustic model, tokenizer, language model or framework execution.'}
    Path('report.json').write_text(json.dumps(report,indent=2,allow_nan=False)+'\n',encoding='utf-8')
    print(f'checks passed: collapse order, total mass, minimum frames, {checked} forward/enumeration pairs')


if __name__ == '__main__':
    main()

怎样运行:在独立练习文件夹中执行 python -B ctc_paths.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。固定逐帧概率的 CTC 路径枚举与前向递推;未训练声学模型、未运行框架、beam search 或语言模型。

运行后对照结果
greedy frame path=__; collapsed=<empty>; path probability=0.250000
label=a: summed probability=0.560000
label=<empty>: summed probability=0.250000
label=b: summed probability=0.110000
label=ab: summed probability=0.040000
label=ba: summed probability=0.040000
collapse a_a=aa; collapse aa_=a
target aa: minimum frames=3; T=2 probability=0.000000; T=3 probability=0.294000
checks passed: collapse order, total mass, minimum frames, 75 forward/enumeration pairs

可选:用真实 CTCLoss 核对这六个目标

另用本机已有的 PyTorch 2.9.1+cpu,实际调用 CTCLoss;没有安装或升级依赖。每帧固定 blank=0.5、a=0.4、b=0.1,分别用两帧、三帧检查 a、aa 和空串。CPU、float64、单线程,输入对数概率形状为 [T,1,3],blank=0,reduction="none"。

保留未归约的原始损失后,可以把 exp(−loss) 与同标签的全部路径概率相比较。本次六项都在 10⁻¹² 容差内相等,最大绝对差约 1.110×10⁻¹⁶;两帧得到 a 为 0.56、aa 为 0、空串为 0.25,三帧分别为 0.524、0.08、0.125。默认 mean 还涉及按目标长度与批次归约,因此不能直接拿默认输出做这项逐样本概率核对。

三帧 aa 的唯一合法路径仍是 a,blank,a;这份概率表给出 0.4×0.5×0.4=0.08。前文另一个三帧反例使用 0.7、0.6、0.7,所以得到 0.294。路径规则相同,输入概率表不同;对照结果之前要先确认两边用的是同一张表。

两帧 aa 的原始损失为正无穷,exp(−loss)=0。改为 zero_infinity=True 后,实际返回损失 0,但所有合法路径的总概率仍为 0。此时再把 exp(−0)=1 解释成目标概率就错了:这个选项改写了损失值,没有让目标变得可达。文档也说明相关梯度会被置零;本次没有求梯度,因此只把损失行为列为实测结果。

ctc_torch_cpu.py 可用已有 PyTorch 的解释器独立运行,另写 ctc-torch-report.json。它以有理数精确枚举作为参照;制作时还把六项结果与正文基础脚本的枚举函数交叉核对。此次只验证固定输入的 CPU 接口、损失选项与概率关系,没有声学模型、参数更新、识别率评测、GPU 或长序列稳定性验证。

展开收起代码 · 小实验python · 88 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Actual CPU CTCLoss versus exhaustive paths; fixed inputs, no training."""
from collections import defaultdict
from fractions import Fraction
from itertools import product
import json
import math
from pathlib import Path
import platform
import sys
import torch

torch.set_num_threads(1)
SYMBOLS = ('_', 'a', 'b')
FRAME = (Fraction(1, 2), Fraction(2, 5), Fraction(1, 10))


def enumerate_fixed_frames(frame_count):
    masses = defaultdict(Fraction)
    for indices in product(range(3), repeat=frame_count):
        path = tuple(SYMBOLS[index] for index in indices)
        merged = [symbol for i, symbol in enumerate(path) if i == 0 or symbol != path[i - 1]]
        label = ''.join(symbol for symbol in merged if symbol != '_')
        masses[label] += math.prod(FRAME[index] for index in indices)
    assert sum(masses.values()) == 1
    return dict(masses)


def framework_loss(frame_count, label, zero_infinity=False):
    probabilities = torch.tensor([float(value) for value in FRAME], dtype=torch.float64, device='cpu')
    log_probs = probabilities.log().repeat(frame_count, 1).unsqueeze(1)
    targets = torch.tensor([SYMBOLS.index(char) for char in label], dtype=torch.long, device='cpu')
    input_lengths = torch.tensor([frame_count], dtype=torch.long, device='cpu')
    target_lengths = torch.tensor([len(label)], dtype=torch.long, device='cpu')
    loss = torch.nn.CTCLoss(blank=0, reduction='none', zero_infinity=zero_infinity)(
        log_probs, targets, input_lengths, target_lengths)
    assert list(log_probs.shape) == [frame_count, 1, 3]
    assert list(loss.shape) == [1]
    return loss.item()


def main():
    print(f'Python {platform.python_version()}; torch {torch.__version__}; device=cpu; dtype=float64; threads=1')
    print('each frame: blank=0.5, a=0.4, b=0.1; blank index=0; reduction=none; batch size=1')
    cases = []
    for frame_count in [2, 3]:
        reference = enumerate_fixed_frames(frame_count)
        for label in ['a', 'aa', '']:
            expected = reference.get(label, Fraction(0))
            loss = framework_loss(frame_count, label)
            recovered = math.exp(-loss)
            error = abs(recovered - float(expected))
            assert error < 1e-12
            if expected == 0:
                assert math.isinf(loss) and loss > 0
            else:
                assert math.isfinite(loss)
            loss_text = f'{loss:.6f}' if math.isfinite(loss) else 'inf'
            print(f'T={frame_count} target={label or "<empty>"}: loss={loss_text}; exp(-loss)={recovered:.6f}; '
                  f'enumerated={float(expected):.6f} ({expected}); error={error:.3e}')
            cases.append({'frames': frame_count, 'target': label,
                          'loss': loss if math.isfinite(loss) else 'Infinity',
                          'exp_negative_loss': recovered, 'enumerated_probability': float(expected),
                          'exact_probability_fraction': str(expected), 'absolute_error': error})
    assert enumerate_fixed_frames(2).get('aa', Fraction(0)) == 0
    assert enumerate_fixed_frames(3)['aa'] == Fraction(2, 25)
    zeroed_loss = framework_loss(2, 'aa', zero_infinity=True)
    assert zeroed_loss == 0.0
    raw_exponent_of_zeroed = math.exp(-zeroed_loss)
    assert raw_exponent_of_zeroed == 1.0
    assert enumerate_fixed_frames(2).get('aa', Fraction(0)) == 0
    print('T=2 target=aa with zero_infinity=True: loss=0.000000; exp(-zeroed loss)=1.000000; actual path probability remains 0.000000')
    print('zero_infinity changes the loss value; exp(-zeroed loss) is not the impossible target probability')
    print('checks passed: 6 CPU CTCLoss/path comparisons, empty targets, repeated-label reachability, zero_infinity distinction')
    report = {'python': sys.version, 'executable': sys.executable, 'torch': torch.__version__,
              'torch_path': torch.__file__, 'device': 'cpu', 'dtype': 'float64',
              'threads': torch.get_num_threads(), 'frame_probabilities_blank_a_b': [0.5, 0.4, 0.1],
              'blank': 0, 'reduction': 'none', 'cases': cases,
              'max_probability_error': max(case['absolute_error'] for case in cases),
              'zero_infinity_case': {'frames': 2, 'target': 'aa', 'raw_loss': 'Infinity',
                                     'zeroed_loss': zeroed_loss,
                                     'exp_negative_zeroed_loss': raw_exponent_of_zeroed,
                                     'enumerated_probability_unchanged': 0.0},
              'scope': 'Actual CPU PyTorch CTCLoss on fixed 2/3-frame probabilities and targets a/aa/empty, reduction none; exact rational path enumeration and zero_infinity comparison. No acoustic model, optimizer, training, GPU, decoding benchmark or gradient evaluation.'}
    Path('ctc-torch-report.json').write_text(json.dumps(report, indent=2, allow_nan=False) + '\n', encoding='utf-8')


if __name__ == '__main__':
    main()

怎样运行:python -B ctc_torch_cpu.py

运行后对照结果
Python 3.14.0; torch 2.9.1+cpu; device=cpu; dtype=float64; threads=1
each frame: blank=0.5, a=0.4, b=0.1; blank index=0; reduction=none; batch size=1
T=2 target=a: loss=0.579818; exp(-loss)=0.560000; enumerated=0.560000 (14/25); error=1.110e-16
T=2 target=aa: loss=inf; exp(-loss)=0.000000; enumerated=0.000000 (0); error=0.000e+00
T=2 target=<empty>: loss=1.386294; exp(-loss)=0.250000; enumerated=0.250000 (1/4); error=0.000e+00
T=3 target=a: loss=0.646264; exp(-loss)=0.524000; enumerated=0.524000 (131/250); error=1.110e-16
T=3 target=aa: loss=2.525729; exp(-loss)=0.080000; enumerated=0.080000 (2/25); error=1.388e-17
T=3 target=<empty>: loss=2.079442; exp(-loss)=0.125000; enumerated=0.125000 (1/8); error=2.776e-17
T=2 target=aa with zero_infinity=True: loss=0.000000; exp(-zeroed loss)=1.000000; actual path probability remains 0.000000
zero_infinity changes the loss value; exp(-zeroed loss) is not the impossible target probability
checks passed: 6 CPU CTCLoss/path comparisons, empty targets, repeated-label reachability, zero_infinity distinction

这里没有训练一个语音模型

逐帧概率来自手工给定表,没有录音、声学编码器或图像文字识别器。乘积形式是 CTC 对给定输入的路径建模方式;不意味着真实声音帧在物理上彼此独立,也不排斥编码器利用上下文。

基础运行核对了小例子的路径归并、概率求和和递推一致性;后面的可选对照另外实际执行 PyTorch 2.9.1+cpu 的 CTCLoss,并与同一固定概率表的路径枚举比较。没有验证识别准确率或端到端解码性能。要继续到真实任务,需要另外核对输入长度、标签编码、blank 约定和评测集。

记住这些要点

  • CTC 先合并相邻重复,再移除 blank,顺序不能交换。
  • 最可能路径折叠后的标签,不一定有最大的标签总概率。
  • 连续重复字符需要额外 blank 帧分隔。
  • zero_infinity 处理数值,不会让不可能的对齐变成可能。

别把例子用过头

  • 先删除 blank,导致 aa 被错误合并成 a。
  • 用单条最佳路径的概率替代所有对齐路径的和。
  • 只比较目标长度与帧数,忽略连续重复字符额外占帧。
  • 跨框架默认 blank 索引不同却未显式指定。

自己试一小步

把两帧空白概率逐渐从 0.4 增到 0.8,贪心结果与最大标签结果在哪些区间不同?

  1. 保持 b 概率固定为 0.1,a 概率补足到 1。
  2. 每次穷举全部路径,分别报告最佳路径与最佳标签。
  3. 约定平局时的选择方式,并测试临界值两侧。
  4. 扩到三帧,比较差异区间是否仍然相同。

做到这些,就算完成

  • 每帧概率非负且和为 1。
  • 全部标签概率总和接近 1。
  • 明确区分路径概率与同标签路径总概率。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。