简介:面向机器学习与文本挖掘方向的学习者,这份资源围绕吉布斯采样(Gibbs Sampling)与 LDA 主题模型的结合展开,提供一套可编译运行的采样实现,适合想理解 MCMC 推断流程并动手复现主题模型的中高级读者。压缩包共 15 个文件、约 55KB,以 5 个头文件与 4 个 C++ 源文件为核心,另有 Makefile、CMake 构建脚本、README、说明网页及 PDF 手册;体量轻便,却覆盖数据读取、词典构建、模型采样到结果输出的完整链路。资源已有 604 人学习下载,说明其在主题模型入门与代码研读场景中具备一定参考价值。读者可从中梳理吉布斯采样在 LDA 中的迭代更新逻辑,对照头文件与源文件理解数据结构与采样函数的分层设计,并借助构建脚本与手册完成编译与调试,为后续在 MATLAB 等环境中复现和改造留下清晰参照。
1. 从一段四十行的采样循环说起:GibbsLDA 到底解决什么
拿到一份 GibbsLDA 的 MATLAB 代码,多数人的第一反应不是读,而是先跑一遍看它到底出不出主题。这份系统开源实现走的是坍缩吉布斯采样(Collapsed Gibbs Sampling)路线:不显式采样 φ 和 θ,把两组狄利克雷先验积分掉,只对每个词的主题标签 z 逐个重采样,因此每轮只需维护三张计数表。它适合两类人——手上有几千到几万条短文本、想先跑出一张能看的话题表来验证想法的人;以及要把主题特征喂给下游分类器、需要一个可改可调的 MATLAB 基线的人。短板同样直白:单线程、纯 for 循环、没有向量化,K 一上百、语料一过十万词,速度就慢到让人怀疑人生。所以真正要弄清的不是“LDA 是什么”,而是这套代码在哪个环节吃时间、哪些参数会直接毁掉结果。
2. 坍缩吉布斯采样的条件分布:把公式还原成二十行 MATLAB
2.1 为什么先坍缩掉 φ 和 θ
标准 LDA 有两层随机变量:文档-主题分布 θ_d ~ Dir(α),主题-词分布 φ_k ~ Dir(β)。如果用普通的吉布斯采样,每轮要分别对 θ、φ、z 三组变量采样,状态空间大、收敛慢,而且 θ 和 φ 是连续参数,采样出来还得再做一轮归一化。坍缩的做法是利用狄利克雷-多项式的共轭性,把 θ 和 φ 直接积分掉,只留下离散的主题标签 z。积分之后,条件分布里出现的是“计数 + 先验”这种极其好算的形式,一轮迭代的成本从“采样 K×W 个连续值”降到“给 N 个 token 各抽一次多项分布”。
代价是明确的:你拿不到采样过程中 φ 的不确定性估计,只能拿到点估计(后验均值形式的平滑计数)。对做主题词表、做文档向量这类下游任务来说,这个代价基本可以忽略;但如果你的目标是贝叶斯模型比较、要给 φ 做区间估计,坍缩版本就不合适了,得回到不坍缩的变分或者 HMC。判断标准很简单——下游只用 top 词和 theta 向量,就用坍缩;下游要算后验方差,就别用。
另一个常被忽略的点是坍缩后的采样是“逐 token 顺序”的,token i 的采样依赖 token i-1 更新后的计数。这意味着这个循环天生不能并行,任何试图用 parfor 包一层的改动都会引入竞态,结果看上去能跑,主题质量却明显下降。这是后面讲复现性时还会再提一次的根因。
2.2 条件分布拆成三个计数矩阵
坍缩吉布斯的核心公式只有一行,对第 d 篇文档里第 i 个位置、当前词为 w 的 token,把它重新分到主题 k 的概率是:
p(z_i = k | z_{-i}, w) ∝ (n_{dk}^{-i} + α) × (n_{kw}^{-i} + β) / (n_k^{-i} + W·β)
三个量分别对应三张计数表,理解它们的形状比背公式有用得多:
- n_mz:M×K 的文档-主题计数,第 d 行第 k 列是文档 d 里被判给主题 k 的词数。
- n_zw:K×W 的主题-词计数,第 k 行第 w 列是词 w 被判给主题 k 的次数。
- n_z:K×1 的主题总计数,等于 n_zw 每行求和,也等于 n_mz 每列求和。
公式的直觉是这样的:第一项 (n_dk + α) 偏好“这篇文档已经用过的主题”,第二项 (n_kw + β)/(n_k + Wβ) 偏好“这个主题已经解释过这个词”。两项相乘就是一个“文档亲和图”与“主题词亲和图”的乘积。上标 -i 表示先把当前 token 自己从计数里摘出去,否则它会给自己投票,采样会退化成近似确定性的过程。
分母 n_k + W·β 是归一化项,注意它是标量、只跟 k 有关,所以实际计算时不必真的构造 K×W 的中间矩阵——每个 token 只需要 n_zw 的一列。这正是后面做向量化优化时唯一值得动的地方。
2.3 单步采样的 MATLAB 落地与参数说明
把公式翻译成代码,最难的不是数学而是“摘出去再放回来”这一步的顺序。下面的写法是我习惯用的结构,先减后算再加,一步不落:
function [z, n_mz, n_zw, n_z] = gibbs_sweep(w, d, z, n_mz, n_zw, n_z, alpha, beta, K, W) % w, d: N×1 的全局词 ID 与文档 ID(按文档顺序拼接) % z : N×1 当前主题分配,取值 1..K,MATLAB 下标从 1 开始 % alpha: K×1 或标量,每主题先验;beta: 标量或 1×W 非对称先验 N = numel(w); for i = 1:N wi = w(i); di = d(i); zi = z(i); % 1) 先把这个 token 从三张计数表里摘掉,保证 -i 语义 n_mz(di, zi) = n_mz(di, zi) - 1; n_zw(zi, wi) = n_zw(zi, wi) - 1; n_z(zi) = n_z(zi) - 1; % 2) 未归一化条件概率,注意 n_zw(:,wi) 是 K×1 列向量 p = (n_mz(di, :)' + alpha) .* (n_zw(:, wi) + beta) ./ (n_z + W * beta); p = p / sum(p); % 3) 累积分布 + 反变换采样,比 randsample 快一个量级 cum = cumsum(p); r = rand() * cum(end); % 乘 cum(end) 而不是 1,防浮点误差越界 k = find(cum >= r, 1, 'first'); if isempty(k), k = K; end % 兜底,理论上不该走到 % 4) 把 token 按新主题放回去 z(i) = k; n_mz(di, k) = n_mz(di, k) + 1; n_zw(k, wi) = n_zw(k, wi) + 1; n_z(k) = n_z(k) + 1; end逻辑说明:第 1 步的减法是整个函数正确性的命门。如果漏掉减法,n_kw 里会包含当前 token 自己,采样结果会向“保持当前主题”严重倾斜,跑出来的主题数往往远少于 K,看起来像是“模型自动选出了主题数”,其实是自投票造成的伪收敛。第 2 步中n_mz(di,:)'必须转置成列向量,否则会和n_zw(:,wi)做外积,得到一个 K×K 矩阵,sum(p)就变成一个标量,采样直接崩掉。
参数说明:alpha可以是标量也可以是 K×1 向量,写成向量就得到非对称先验,适合你已经知道某些主题该更“小”的场景;beta写成 1×W 就是非对称词先验,常见做法是给停用词一个极小的 β(比如 1e-8)而不是直接删掉,这样能在概率层面压住它们而不破坏文档长度。W一定要传真实的词表大小,因为分母里的 W·β 会影响稀疏主题的得分。
第 3 步用cumsum加find而不是randsample(p,1,true),原因很实际:randsample内部会做一遍参数校验和归一化,在 N=5×10^5、迭代 1000 轮的规模下,这个开销能占到总时间的四成以上。r = rand() * cum(end)里的乘法是血泪经验,因为 p 归一化后 cum(end) 理论上等于 1,但浮点累加常常得到 0.9999999999,一旦 rand() 落在这个尾巴区间,find会返回空,整轮循环就抛错。
3. 语料预处理与工程化输入:词典、ID 映射和文档长度检查
3.1 从原始文本到整数 ID 的三步映射
这套代码的输入不是字符串,而是一条扁平的整数 token 流加上并行的文档 ID。中间要做三件事:切词、建词表、映射 ID。看起来平平无奇,但这里埋的坑比采样循环还多,因为错在这层时程序不会报错,只会安静地输出一堆垃圾主题。
function [w, d, vocab, docs] = build_corpus(raw, min_len, min_count) % raw: cell 数组,每个元素是一篇已分好词的字符串(空格分隔) % min_len : 文档最少词数,低于此值丢弃,防 theta 出现空行 % min_count: 词表最小出现次数,过滤 hapax 把 W 压下来 docs = {}; for i = 1:numel(raw) t = strsplit(strtrim(raw{i})); t = t(~cellfun(@isempty, t)); % 处理连续空格/制表符 t = lower(t); % 大小写统一,否则 Apple/apple 算两个词 if numel(t) < min_len, continue; end % 长度闸门,必须在校验之后 docs{end+1, 1} = t; end allt = [docs{:}]; [uwords, ~, idx] = unique(allt); % unique 会按字典序排序,ID 可复现 cnt = accumarray(idx(:), 1); keep = uwords(cnt >= min_count); vocab = containers.Map(keep, 1:numel(keep)); w = []; d = []; for i = 1:numel(docs) [tf, ids] = ismember(docs{i}, keep); % 不在词表里的词返回 0 ids = ids(tf); if isempty(ids), continue; end w = [w; ids(:)]; d = [d; repmat(i, numel(ids), 1)]; end end逻辑说明:unique顺手做了排序,所以词 ID 与语料出现顺序无关,同一份语料跑两次得到的词表完全一致,这对后面做 A/B 对照很关键。ismember返回的tf是逻辑掩码,用它一次性过滤掉低频词,比循环 lookup 快得多,也避开了containers.Map对未登录词抛异常的问题。
参数说明:min_len一般取 3~5。取太小会把“转发微博”“哈哈哈”这类无信息文档放进来,它们只贡献噪声计数;取太大会砍掉短文本场景下的有效样本。min_count在几万词规模的语料上取 2~5,能把 W 从十几万压到两三万,K×W 的计数矩阵也就从 8 GB 量级降到几百 MB。注意min_count不要设太大,词表被砍到几千时,β 的相对权重会被放大,主题会变得非常“泛”。
预处理还有一步强制性动作:停用词和标点必须在建词表之前处理掉,而不是指望模型自己学出来。坍缩采样对高频词没有天然的惩罚机制,β 只在分子上做平滑,一个占语料 5% 的“的”字足以霸占所有主题的 top1。
3.2 超参数 α、β、K 的取值与量纲
这块是新手最容易翻车的地方,因为文献里 α=0.1、经典 MATLAB 实现里 α=50/K,两个写法看起来都“对”,但含义完全不同:前者是“每个主题一份 0.1”,后者是“K 个主题总共 50”。K=20 时前者总质量 2,后者总质量 50,相差 25 倍,结果就是后者的 theta 极端平滑,所有文档都长得差不多。
| 参数 | 常见取值 | 物理含义 | 调大的后果 |
|---|---|---|---|
| K | 20~100 | 主题数 | 主题细碎、top 词高度重叠 |
| α(每主题) | 0.1(总 0.1K) | 文档-主题稀疏度 | 文档混主题,theta 接近均匀 |
| β | 0.01(或 1e-3) | 主题-词稀疏度 | 主题词变泛,低频词被拉高 |
| 迭代轮数 | 1000(小语料 500) | 采样步数 | 收益递减,200 轮后基本稳定 |
我的习惯是:先把 α 固定成每主题 0.1,β 取 0.01,只调 K。等 K 定下来再看 theta 的分布——如果大部分文档的 theta 最大值不到 0.3,说明 α 偏大,往 0.05 压;如果某几篇文档的 theta 几乎是一条 one-hot,说明 α 偏小,反而要加。β 一般不动,除非词表被压到一万以内,这时 β=0.01 相当于给每个词垫了 1% 的均匀质量,会让所有主题的 top 词尾部变得雷同。
K 的选择不要靠“肘部法”这种需要跑十几次的办法,成本太高。实际做法是:K 从 30 起,跑一次看 top 词,如果相邻几个主题的 top10 有超过一半重叠,就把 K 减半重跑;如果所有主题的 top10 都是“的、是、我、你、他”,说明 K 太大、模型在切噪声。
3.3 初始化与 burn-in:前一百轮该不该丢
初始化有两种:全零和随机。全零初始化在数学上是合法起点,因为条件分布里 α、β 保证了每个主题都有非零概率,但实际跑起来前几十轮会呈现出明显的“抢词”现象——先被抽到的词构成一个种子主题,后面的词被它大量吸走,收敛慢且容易停在坏局部解。随机初始化(z = randi(K, N, 1))稍微好一点,但同样有随机性带来的不稳定。
我一般用“随机初始化 + 固定种子”,两头都要:
rng(20240101, 'twister'); % 固定种子,放在所有随机操作之前 z = randi(K, numel(w), 1); % 1..K 的随机主题分配 [n_mz, n_zw, n_z] = init_counts(w, d, z, K, W); nburn = 100; % 前 100 轮只更新状态,不统计 for it = 1:niters [z, n_mz, n_zw, n_z] = gibbs_sweep(w, d, z, n_mz, n_zw, n_z, alpha, beta, K, W); if it > nburn % 这里再累加 theta/phi 的滑动平均,或者记录每轮的 loglik end end逻辑说明:rng(...,'twister')指定了生成器类型,只写rng(seed)在老版本 MATLAB 上会沿用当时默认的生成器,换台机器结果就变了。nburn的作用不是“让模型收敛”——200 轮之后 z 的边缘分布基本稳定——而是避开初始化残留的偏差。如果你只取最后一次迭代的计数算 phi,burn-in 其实可以设得很小;如果要沿迭代轨迹做平均(更稳的做法),burn-in 就必须留足。
参数说明:niters设 1000 是保守值,多数几千文档的语料在 300~500 轮后就只剩小幅抖动。判断依据是每 50 轮记录一次对数似然,如果后 200 轮的增量小于总量的 0.5%,继续跑就是浪费电。
4. 输出解析与可信度验证:从 phi、theta 到能看的话题表
4.1 三个输出矩阵的形状与归一化
采样结束后,代码里留下的是三张计数表,但真正要交付的是两个概率矩阵。注意归一化的分母各不相同,写错一个就会得到“每行和不为 1”的矩阵,下游做文档向量时会莫名其妙地整体偏小。
% n_mz: M×K, n_zw: K×W, n_z: K×1 theta = (n_mz + alpha) ./ (sum(n_mz, 2) + K * alpha); % M×K,每行和为 1 phi = (n_zw + beta) ./ (n_z + W * beta); % K×W,每行和为 1 % 导出可读话题表 for k = 1:K [~, ord] = sort(phi(k, :), 'descend'); top = keep(ord(1:15)); % keep 是词表,与 phi 列对齐 fprintf('T%02d: %s\n', k, strjoin(top, ' ')); end逻辑说明:n_z + W*beta是 K×1 的列向量,n_zw是 K×W,这里的./依赖 MATLAB 的隐式扩展(R2016b 之后才有),按列广播是正确的——第 k 行的分母就是第 k 个主题的 n_k + Wβ。老版本必须写成bsxfun(@rdivide, n_zw + beta, n_z + W*beta),否则会报维度不匹配,或者更糟:在某些写法下变成按行广播,结果看起来正常但完全错位。
参数说明:alpha在 theta 的分母里是K*alpha,前提是 α 为标量。如果用了 K×1 的非对称先验,分母要改成sum(alpha)。这一点在改代码时极易漏掉,症状是 theta 每行的和是 0.98 或者 1.03 这类奇怪的数。
导出 top 词时务必用keep(或vocab的反向映射)去查表,而不是直接拿列号当词。词表在预处理阶段被unique排过序,列号 1 对应的是字典序最小的词,直接输出会得到一串数字。这个错误我见过不止一次,排查时盯着 phi 看了半小时才反应过来是映射层的问题。
4.2 困惑度与对数似然:判断模型是不是真的在收敛
主题词好看不代表模型收敛,尤其在小语料上,随机初始化的两组结果可能都“看起来还行”。可靠的判据是对数似然随迭代的走势。坍缩采样的对数似然可以直接从计数表算出来,不用另写一套公式:
loglik = 0; for i = 1:numel(w) p_i = theta(d(i), :) * phi(:, w(i)); % 标量:该 token 的边缘概率 loglik = loglik + log(p_i + eps); % eps 兜底,防 log(0) end perplexity = exp(-loglik / numel(w));逻辑说明:theta(d(i),:)是 1×K,phi(:,w(i))是 K×1,相乘得标量,含义是“文档 d 生成词 w 的边缘概率”。对整个语料取负对数平均再取指数,就是 perplexity。eps兜底不能省,因为空文档或者被过滤到长度为 0 的文档会让p_i恰好为 0,log(0)得到-Inf,整个 perplexity 变成NaN,而这个 NaN 会一路污染后面的所有实验记录。
参数说明:这个 perplexity 是在训练集上算的,只能用来判断“有没有在收敛”,不能用来做模型选择——它天然偏好 K 更大的模型。想看泛化表现,得留出一部分文档做 held-out:把这些文档的 token 从采样中排除(只更新计数不加进去),采样结束后用同样的公式算它们的 perplexity。这套代码通常不提供 held-out 接口,得自己在gibbs_sweep里加一个is_train掩码,改动量不大但很值得。
一个实用经验:正常收敛时 perplexity 在前 50 轮下降很快,之后缓慢下滑;如果它在 100 轮附近开始上下震荡甚至反弹,八成是 α 太小导致主题在文档间来回跳,把 α 抬到 0.2 试试。
4.3 用固定种子做 A/B 对照
调参时最怕的不是参数不对,而是“这次比上次好”其实是随机种子的差异。这套代码里的随机性只有一处(每轮每个 token 的rand()),所以只要种子固定、迭代数固定、token 顺序固定,两次运行的结果就完全一致。这三条里最容易破的是 token 顺序——如果预处理时用了unique之外的排序,或者过滤条件随参数变化(比如 min_count 改了),token 序列就变了,种子固定也没用。
我一般会在跑对比前先落一个基线快照:把 w、d、K、alpha、beta、seed 打包存成 mat 文件,之后每组实验都从这个快照加载,保证输入完全一致。这样比较 K=30 和 K=50 时,差异才真的来自 K。
另外,单次运行的结果噪声不小,尤其 K 大的时候。稳妥的做法是每个配置跑 3 个不同种子,比较 perplexity 的均值和方差,而不是只看一次。3 次听起来不多,但足以让你发现“某组配置的方差是别人的五倍”这种问题——那种配置通常是在收敛边缘反复横跳,不能用于交付。
5. 避坑与排查:全零主题、NaN 困惑度的五个现场
5.1 现象:所有主题的 top 词都是“的、是、我”
原因不在采样,在预处理。中文分词后没有去停用词,也没有过滤单字符和纯标点,而坍缩采样对高频词没有任何抑制作用——β 只在分子上做加性平滑,一个占语料 5% 的词会在每一轮里稳定获得高概率,最后霸占全部 K 个主题的第一位。
解决:在建词表之前挂一张停用词表,同时把长度小于 2 的 token、纯数字、纯标点全部过滤掉。如果不想维护停用词表,退而求其次的办法是给停用词一个极小的 β(1e-8)而不是删除,这样文档长度不变、计数结构不变,但它们在条件概率里的权重被压到几乎为零。注意 β 是标量时改不了单词语权重,得换成 1×W 的向量形式。
5.2 现象:theta 几乎是均匀分布,每篇文档都像“什么主题都沾一点”
原因:α 被按“总量”而不是“每主题”设置。经典实现里写alpha = 50/K,K=20 时总先验质量是 50,而语料里平均每篇文档才几十个词,先验直接盖过了数据,后验自然被拉成均匀分布。这和文献里 α=0.1 的约定差了一个数量级。
解决:统一成“每主题 0.1”,代码里写alpha = 0.1(标量形式就是每主题一份),总质量自动是 0.1K。改完重跑,theta 的最大值应该从 0.1 左右抬到 0.3~0.6。判断是否合适的快速方法:统计所有文档 theta 最大值的均值,落在 0.4~0.7 之间算健康;低于 0.3 说明 α 太大,高于 0.85 说明 α 太小。
5.3 现象:perplexity 输出 NaN 或 Inf,但采样本身没报错
原因:语料里存在空文档。预处理时如果先建词表后过滤文档,某些文档的所有词都被 min_count 滤掉了,它们的 token 数为 0,theta 那一行虽然不报错(分母是 K·α,非零),但在算 perplexity 时sum里根本没有这批文档的贡献,而循环里theta(d(i),:)的索引又可能越界或指向错误的文档。另一种情况是某个 token 的p_i恰好下溢到 0。
解决:两道防线。第一道在预处理,先切词、先去停用词、再判长度,顺序不能反,min_len闸门放在ismember之前管的是原始词数,之后还要再判一次过滤后的长度。第二道在 perplexity 计算里加eps,并且统计一遍sum(n_mz, 2) == 0的行数,非零就打印警告。这个检查成本极低,但能省掉很多“跑了一夜结果全是 NaN”的惨剧。
5.4 现象:同一个种子跑两次,主题词表不一样
原因通常有三个。最常见的是rng没有指定生成器类型,只写了rng(42),在不同 MATLAB 版本上默认生成器不同,序列也就不同。第二个是采样循环被parfor包了一层,并行 worker 各自的随机流互相干扰,即使种子相同结果也不可复现。第三个是预处理阶段的unique之外还用了某种不稳定排序(比如按频率排序时并列项的顺序未定义),导致词 ID 顺序漂移。
解决:统一写rng(seed, 'twister')并且放在脚本最前面、所有随机操作之前;采样循环严禁并行,要提速只能走向量化,不能走多线程;词表排序固定用unique的字典序,需要按频率展示时单独做一次带稳定 tie-break 的排序(先按频次降序,再按词 ID 升序)。
5.5 现象:跑到一半内存爆掉,或者计数出现莫名其妙的负数
原因:n_zw 是 K×W 的稠密 double 矩阵。K=100、W=100000 时就是 8×10^7 个 double,约 640 MB,再算上采样过程中 MATLAB 的临时变量和写时复制,实际占用常常翻两三倍。负数则另有原因:减计数的时候用了整型(int32/uint8)存储 Joint 计数,n_mz(di,zi) - 1在计数为 0 时下溢成 255 或 -1,之后的概率全是负的。
解决:计数矩阵一律用 double,一个字节都别省,因为你要拿它做除法和累加,整型的溢出行为是隐含的、不报错的。内存方面,如果 W 确实很大,把 n_zw 换成sparse(K, W),但要注意稀疏矩阵的单元素读写极慢,正确做法是采样时用稠密矩阵、每隔若干轮把已经稳定的部分固化,或者直接上 5.5 之外的方案——先做词表裁剪把 W 压到 3 万以内,这比任何数据结构优化都有效。
6. 把一千轮压进可接受时间:分母缓存与向量化的一点改造
原版循环里每个 token 都要算一次n_z + W*beta,这是 K 次加法和 K 次除法,而 n_z 每步只变一个元素。把它缓存成倒数向量,等于把每 token 的 K 次除法压成 1 次:
invden = 1 ./ (n_z + W * beta); % K×1,进入循环前算一次 for i = 1:N wi = w(i); di = d(i); zi = z(i); n_mz(di, zi) = n_mz(di, zi) - 1; n_zw(zi, wi) = n_zw(zi, wi) - 1; n_z(zi) = n_z(zi) - 1; p = (n_mz(di, :)' + alpha) .* (n_zw(:, wi) + beta) .* invden; cum = cumsum(p); k = find(cum >= rand() * cum(end), 1, 'first'); if isempty(k), k = K; end z(i) = k; n_mz(di, k) = n_mz(di, k) + 1; n_zw(k, wi) = n_zw(k, wi) + 1; n_z(k) = n_z(k) + 1; invden([zi k]) = 1 ./ (n_z([zi k]) + W * beta); % 只刷新两个位置 end关键改动是最后一行:只有被减掉的主题 zi 和被加上的主题 k 的分母变了,其余 K-2 个元素不受影响。invden([zi k])的花销是常数级的,比每步重算整个向量省下大量时间。实测在 K=50、W=2 万、N=30 万的语料上,这一处改动能砍掉三成左右的运行时间,而且完全不改变采样结果——概率值只差最后一位的浮点误差。
第二个值得做的改造是把find(cum >= r, 1)换成基于discretize的写法,但要注意discretize需要单调递增的分点且边界语义与find不同,改完必须用固定种子对比 z 序列,一位都不能差。我自己的习惯是先用原版跑 20 轮存下 z,改完再跑 20 轮比对,一致才继续。
| 改造项 | 预期收益 | 风险 |
|---|---|---|
| 分母倒数缓存 | 25%~35% 时间 | 几乎无,结果等价 |
| cumsum 替代 randsample | 30%~40% 时间 | 需处理浮点尾巴 |
| 词表裁剪(min_count) | 内存降一个量级 | 过度裁剪伤主题质量 |
| 稀疏 n_zw | 内存降,速度降 | 单元素写入极慢,不推荐 |
那张表里最右边一列是我踩过的坑:稀疏化听起来很美,实际跑起来因为每个 token 都要对 n_zw 做两次单元素读写,MATLAB 的稀疏索引开销比稠密矩阵高出好几倍,整体反而更慢。真正有效的内存手段是砍词表,不是换数据结构。从那以后我每次动采样循环之前,都强制先用固定种子跑 20 轮存一份 z 快照,改完必须逐位比对通过才敢放开跑全量——这个习惯救过我好几次,也让我明白这类代码的优化空间几乎全在“少算重复的东西”上,而不在换更 fancy 的数据结构。希望帮到你。
本文还有配套的精品资源,点击获取