从零构建语言模型训练循环:CS336作业踩坑实录
1. 这门课的“从零开始”指的是什么CS336 的 Assignment 1 叫 Training Loop我第一次看到这个标题时以为会有一大堆炫技的网络结构要写结果把课程说明读完才明白这门课就是让我亲手把语言模型训练的主循环从零搭出来。不是调库不是用一个 Trainer 点两下而是从原始文本开始一步步把 tokenizer、数据加载、模型前向、loss、反向传播、参数更新、评估、checkpoint 全部串起来。这大概是“从零开始构建语言模型”最准确的定义。市面上大多数大语言模型课程要么只讲 transformer 原理要么直接教你怎么调用别人的训练工具你很难看到一个地方把语言模型训练的最底层闭环拆得这么清楚。CS336 这份作业补的就是这个空档。它的目标不是让你训出什么惊艳模型而是让你在写代码的过程里把每一层抽象都亲手拆开过一遍搞清楚一个语言模型到底是怎么学会说话的。所以说这份笔记适合三种人看正在写 CS336 作业、想自学语言模型训练、或者已经在用现成框架跑模型但总觉得哪里隔着一层的人。你可以把这份笔记理解成一个踩坑版作业实录我会把训练循环里那些“看起来简单、动手全是问题”的细节翻出来讲透。1.1 官方到底让你交付什么根据我对 Spring 2025 这个版本的理解Assignment 1 不是让你直接写一个 GPT。课程给出的起点比较朴素用一个小数据集自己实现 tokenizer 和训练循环然后把一个很小的因果语言模型训练起来。你可以用 character-level tokenizer也可以用简单的 BPE可以用一层 attention也可以先用 bigram/trigram 方式做 baseline。重点是训练循环本身必须是你自己写的。交付物一般包括这么几块预处理好的数据和 tokenizer、模型前向代码、loss 计算、完整的训练循环、验证集和测试集上的 loss/perplexity、训练过程的日志和 checkpoint以及最后从训练好的模型里采样生成的文本。你可以理解成作业要求你复现“一个能读、能学、能生成”的最小语言模型闭环。这里有个容易被忽略的点这个作业虽然叫 Training Loop但你把训练循环写对其实是在做整个大语言模型训练流程图的骨架。后面无论你加分布式、加混合精度、加 LoRA、加 RLHF最终落到代码上仍然是在这个循环里插入操作。1.2 它明确不让你用什么CS336 的规则一般会限制你不能直接用transformers.Trainer、PyTorch Lightning这类高封装训练器。不是说这些工具不好而是当你用 Trainer 的时候很多关键决策已经被别人替你做好了。比如数据 batch 里 padding 的 mask 是谁在管、loss 里的忽略位置是谁处理的、梯度裁剪在哪个节点执行、weight decay 怎么和 bias 分开……如果你没有先手动写过一遍这些问题你会以为它们是“自动发生的”。我在第一次看作业时也有点不服气觉得“我已经会用模型库了写原始训练循环不就是上课的内容吗”。等真正动手才发现坑比我预期多得多。最典型的是 labels 位移。你预测的是下一个 token那输入和标签必须错开一个位置很多人在这一步没出问题但 batch 里每个样本长度不一致时padding 的位置又把 loss 搞错了。这种问题Trainer 会帮你处理但你不能只会用 Trainer。1.3 这份笔记怎么带你走一遍我准备按一个实际做作业的顺序来写。先拆解训练循环的三层结构再讲数据预处理和 tokenizer 怎么做、模型选型怎么定、loss 怎么算才是“语言模型式的”loss接着给一份可以直接跑起来的最简训练循环代码最后把我在这个作业里踩过的坑整理成排查表。你可以把这篇笔记当成一个“作业陪跑”。我会把每一步的选择逻辑讲清楚而不只是扔一段能跑的代码。因为作业改一改数据、换一换词表代码就得跟着调如果你不理解每一步在做什么后面大概率会卡在“loss 为什么不变”这种问题上。2. 训练循环的核心你要维护的不只是一个 for很多人一想到训练循环脑子里就是for step in range(total_steps)里面做 forward、backward、optimizer.step。这个理解没错但它太粗了。真正写起来的时候你会发现语言模型训练循环至少要同时处理三层循环遍历 epoch 的外层循环、遍历 batch 的内层循环以及每个 batch 内部由前向、反向、更新组成的操作循环。2.1 一句话版本大语言模型训练流程图几乎每个讲大语言模型训练流程图的地方最后画出来都是同一个闭环原始语料 → 清洗 → tokenizer → 按序列长度切成样本 → 组装 batch → 模型前向得到 logits → 和 labels 算交叉熵 → 反向传播 → 梯度裁剪 → optimizer.step() → 记录日志 → 下一个 batch → 一个 epoch 结束 → 评估验证集 → 下一个 epoch。CS336 Assignment 1 要你实现的就是这条线上从 tokenizer 到 optimizer.step() 的每一环。你可以把模型换成很简单的结构但线上的每个节点都必须真实存在不能靠库帮你吞掉。2.2 三层循环每一层出错都不一样第一层是 epoch 循环。它负责控制“模型看了几遍全部数据”。这一层最常见的错误是把训练集的 shuffle 放到 epoch 外面导致每个 epoch 看到的顺序完全一样。对于一个语言模型来说固定的数据顺序会让模型学到某些位置偏差比如总把某一类词跟在固定位置后面。第二层是 batch 循环。它负责把数据切成一个个 mini-batch 喂给模型。这里的关键是 batch 内所有样本的序列长度要对齐。最偷懒的做法是固定seq_len把每个长文本切成长度一样的片段但真正的语料往往长短不一所以你需要 padding而 padding 的位置必须带进 mask。第三层才是大多数人说的“训练循环”每次 step 里做前向、算 loss、清零梯度、反向传播、梯度裁剪、更新参数。这层如果写错最常见的就是 loss 算出来是 NAN或者模型不更新。我给你的建议是把这三层分清楚在写代码时也用不同函数封装。外层函数管 epoch中层函数管 batch内层函数只管一个 step。这样日志、评估、checkpoint 才能插进合适的位置。2.3 为什么训练循环这么容易写歪我见过很多第一次写原始训练循环的人代码看起来每一步都对但训练出来 loss 基本不动。排查到最后多数是这几个原因之一labels 位移错了、padding 的 loss 没有忽略、cross-entropy 用了全局平均而不是按有效 token 平均、或者忘了optimizer.zero_grad()导致梯度累积。零梯度这个错有点反直觉。现代 PyTorch 默认是梯度累加模式如果你在每次 step 前不手动清零上一步的梯度会跟当前梯度加在一起。结果就是每一步更新方向都不对loss 会异常振荡甚至发散。所以我在这个作业里始终建议写训练循环的时候optimizer.zero_grad()、loss.backward()、optimizer.step()这三个操作必须固定顺序并且放在同一个 step 函数里。这样即使后面你要做梯度累积也只是加一个控制条件而不是把整个流程打乱。3. 数据预处理与 tokenizer训练循环的入口很多人以为训练循环是从model(x)开始的其实不是。真正开始的地方是“把文本变成模型能读的数字”。这一步在 CS336 里会占不少篇幅因为你不能直接拿字符串去算 loss。3.1 从原始文本到 token idAssignment 1 里最简单可行的 tokenizer 就是 character-level把每个字符映射成一个整数。比如语料只有 26 个字母加空格标点词表大小可能就是 50 左右。这个方案的好处是简单、没有 OOV词表外词问题坏处是序列很长模型学字符组合的效率比较低。更接近实际语言模型流程的是 BPE。如果你做 BPE 的话需要自己实现 merge 规则、special token 的添加、编码和解码函数。课程一般不会要求你写一个工业级 tokenizer但会要求你理解“词表大小”和“文本表示”的关系。因为后面模型的 embedding 矩阵大小、最终输出层大小都由 vocab_size 决定。无论你用哪种 tokenizer输出都是一串 token id。这里有个最容易忽略的点你必须在训练语料和验证/测试语料上使用同一套 tokenizer并且不能把验证集拿去构建词表。否则会严重高估模型性能因为模型已经“见过”验证集里不可能出现的词表信息了。3.2 输入和标签预测下一个 token语言模型训练的本质是“给前面的 token预测下一个 token”。所以一条样本要同时有 input_ids 和 labels而 labels 就是 input_ids 往后移一位。比如原始 token 序列是[2, 5, 9, 3]那 input_ids 是[2, 5, 9]labels 是[5, 9, 3]。意思是模型看到2应该预测5看到2,5应该预测9看到2,5,9应该预测3。用代码写就是input_ids ids[:-1] labels ids[1:]这个位移看着简单但很多人会在构造 batch 时把它搞混。特别是在做 padding 之后你很可能只对 input_ids 做了 padding却忘了给 labels 做等长 padding结果维度不匹配loss 直接报错。3.3 batching、padding 与 mask当语料里每个样本长度不一样时你要决定 batch 内的长度策略。最省事的是把所有样本统一 pad 到 batch 内最大长度如果你想省显存也可以按长度分桶让长度接近的样本放一起减少无效 padding。padding 带来的问题是pad这个 token 不应该参与 loss 计算。两种常见做法一个是把 labels 中 padding 位置设为-100然后用nn.CrossEntropyLoss(ignore_index-100)另一个是构造 attention_mask在计算 loss 时只对有效位置做平均。第二种写法更通用尤其你后面用 huggingface 风格模型时attention_mask 是标准接口loss criterion(logits.view(-1, vocab_size), labels.view(-1)) if attention_mask is not None: loss (loss * attention_mask.view(-1)).sum() / attention_mask.sum()这里有一个细节F.cross_entropy返回的是每个位置的 loss默认是平均。如果你直接用平均padding 位置也会被算进去导致 loss 被稀释模型在长文本上会表现得比实际更“好”。所以要么显式 mask要么用 ignore_index。另外batch 的构造还涉及到 shuffle。语言模型一般会把整个语料切成多个固定长度的序列然后在这些序列级别上做随机打乱而不是在 token 级别上打乱。这样能保证每个序列内部依然有连续的文本语义同时让不同批次之间的数据分布更稳定。4. 模型结构选择从 bigram 起步但要留好扩展口Assignment 1 的模型部分其实很自由。你可以写一个完全没有 attention 的 bigram 模型也可以直接上一个单层 transformer。但我的建议是先写一个能快速跑通的最简模型把训练循环验证好再决定要不要升级结构。因为训练循环的 bug 和模型结构的 bug 混在一起时你会非常痛苦。4.1 语言模型的几种类型先分类再选型这里要插一句“语言模型分类”。我们常说的大语言模型大多属于因果语言模型causal LM也就是只能根据左侧上下文预测右侧 token。这类模型训练时不能看到未来 token所以 attention 需要加 causal mask。与之对应的是掩码语言模型masked LM比如 BERT随机遮住一部分 token让模型根据左右两边信息预测被遮住的词。还有一类是序列到序列模型encoder 处理输入、decoder 生成输出一般叫 seq2seq。CS336 的 Assignment 1 大概率是让你训练一个因果语言模型因为后续课程要在这个基础上做生成、做 scaling、做对齐。你选模型时不需要一开始就上完整 GPT。最简单的是 bigram 模型只根据当前 token 预测下一个 token不依赖更长历史。这个模型训练极快loss 能从初始值明显下降让你快速验证整个训练循环是通的。然后再把它替换成带 attention 的模型你会发现训练循环代码基本不用改只改 forward 部分。4.2 用 PyTorch 写一个极简语言模型头如果你暂时不想碰 attention可以先写这样一个骨架import torch import torch.nn as nn import torch.nn.functional as F class TinyCausalLM(nn.Module): def __init__(self, vocab_size, d_model64, max_seq_len256): super().__init__() self.token_embedding nn.Embedding(vocab_size, d_model) self.position_embedding nn.Embedding(max_seq_len, d_model) self.lm_head nn.Linear(d_model, vocab_size, biasFalse) def forward(self, input_ids): B, T input_ids.shape positions torch.arange(T, deviceinput_ids.device).unsqueeze(0).expand(B, T) x self.token_embedding(input_ids) self.position_embedding(positions) logits self.lm_head(x) # [B, T, vocab_size] return logits这个模型严格来说只是“查表 线性映射”没有上下文建模能力但它足够让你检查训练循环。logits的形状是[B, T, vocab_size]这个形状是所有因果语言模型 forward 的标准输出。后面你换成 transformer只要保留这个输出形状loss 和 training loop 都不用动。不过我要提醒一句真正做作业时不要只停留在这种模型上。CS336 的后面要求可能会让你在一个更真实的数据集上跑出非随机的 perceptron/perplexity。这时你需要加 causal self-attention。但你可以先把上面的模型跑通再慢慢加。5. 损失函数与评估指标训练循环的仪表盘训练循环跑了之后你靠什么判断模型学得好不好最直接的就是 loss。但语言模型的 loss 有很多隐藏细节必须写对。5.1 Cross-Entropy Loss 怎么算才正确语言模型的输出 logits 形状是[B, T, vocab_size]labels 形状是[B, T]。PyTorch 的CrossEntropyLoss期望输入是[N, C]其中 N 是所有位置数量C 是类别数。所以你需要把 logits 和 labels 都 reshape。简单写法是criterion nn.CrossEntropyLoss(ignore_index-100) logits model(input_ids) # [B, T, V] loss criterion(logits.view(-1, vocab_size), labels.view(-1))如果你不想手动 mask那么在构造 labels 时把 padding 位置改成-100就行。ignore_index-100是 PyTorch 约定俗成的值很多开源代码都这么用。这里有个容易忽略的细节loss 的计算应该按“有效 token 数量”归一化而不是按 batch 里的总 token 数。你用CrossEntropyLoss(ignore_index-100)时 PyTorch 会自动忽略掉-100的位置所以没问题。但如果你手动从左到右写 cross-entropy就要特别小心分母。5.2 Perplexity 是给人类看的损失训练日志里经常会同时打印 loss 和 perplexity。Perplexity 的定义是exp(loss)。如果一个模型在训练集上的 cross-entropy loss 是 6.9那困惑度大约是 1000意味着模型在每次预测时相当于在 1000 个词里随机猜。如果 loss 降到 4.0困惑度就降到约 55说明模型已经缩小了候选范围。实际训练中初期 loss 应当接近ln(vocab_size)。如果你的词表是 1000那初始 loss 应该在 6.9 附近如果一开始就非常低多半是你的 loss 计算有泄漏比如模型看到了正确答案。5.3 训练集、验证集、测试集的三级火箭Assignment 1 会要求你在验证集上评估模型。为什么要单独抽验证集因为训练循环里你每跑一轮就看一下 loss时间久了模型会在验证集上过拟合导致验证集的判断不准确。所以更严格的做法是平时只在训练集上更新参数偶尔看一眼验证集 loss只在所有实验做完了才上测试集。还有一个常见问题是训练集 loss 下降但验证集 loss 上升这是过拟合信号。对小模型来说解决办法不是立刻上更复杂模型而是调低学习率、增加 dropout、减小模型规模、或者加 weight decay。CS336 的作业里验证集指标比你手写的生成样例更能说明问题。6. 完整训练循环的落地实现现在把前面所有环节合到一起写一份能跑的“最小完整版”。你可以在本地用 CPU 跑也可以放到单张 GPU 上跑。核心目标是step 能连续跑几千步loss 从初始值稳步下降。6.1 训练循环的骨架代码import torch import torch.nn as nn from torch.utils.data import DataLoader from torch.optim import AdamW def train_one_epoch(model, loader, optimizer, criterion, device, max_grad_norm1.0, accumulation_steps1): model.train() total_loss 0.0 total_tokens 0 optimizer.zero_grad() for step, batch in enumerate(loader): input_ids batch[input_ids].to(device) labels batch[labels].to(device) attention_mask batch[attention_mask].to(device) logits model(input_ids) # [B, T, V] loss criterion(logits.view(-1, logits.size(-1)), labels.view(-1)) # 如果不用 ignore_index这里手动 mask if attention_mask is not None: loss (loss * attention_mask.view(-1)).sum() / attention_mask.sum() loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() optimizer.zero_grad() total_loss loss.item() * accumulation_steps total_tokens attention_mask.sum().item() if step % 100 0: print(fstep {step}, loss {loss.item() * accumulation_steps:.4f}) return total_loss / max(total_tokens, 1)这份代码已经包含了梯度累积逻辑。如果你暂时不想用梯度累积把accumulation_steps固定为 1 即可。clip_grad_norm放在 backward 之后、optimizer.step 之前这是标准顺序。6.2 学习率、AdamW、梯度裁剪怎么配对于 assignment 这种小规模训练我建议直接用 AdamW初始学习率从3e-4开始试。weight_decay可以用0.1但要注意它不应作用到 embedding 和 bias 上。简单实现里可以直接对所有参数用相同 weight decay问题不大如果你追求更严谨可以把参数分组。学习率调度最好加一个 warmup。训练初期梯度方向很不稳定一上来就用大学习率很容易让 loss 炸掉。常见的做法是前几百步做线性 warmup然后再用 cosine annealing 慢慢降。代码可以这样from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR warmup_scheduler LinearLR(optimizer, start_factor0.1, total_iters200) main_scheduler CosineAnnealingLR(optimizer, T_maxtrain_steps - 200)这里不需要实现得非常复杂但一定要理解学习率是训练循环里最敏感的超参。如果你发现 loss 上下剧烈抖动第一反应应该是调低学习率而不是调模型结构。6.3 checkpoint、日志与断点恢复训练过程中一定要保存 checkpoint否则跑到一半中断你会想哭。checkpoint 不要只存 model 权重最好把 optimizer、scheduler、step 数、模型配置都存下来checkpoint { model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), step: step, config: config, } torch.save(checkpoint, fcheckpoint_{step}.pt)恢复的时候依次加载这些字段并把数据加载器的状态也恢复一下。虽然 CS336 的作业规模不大但养成这个习惯对你以后做任何实际训练都有好处。日志方面不需要一开始就上 fancy 工具。本地训练时先打印step/loss/lr就够了等你想记录更完整的曲线再接入 WandB 之类的工具。关键是日志里一定要有学习率不然你很难判断 loss 突然抖动是不是因为 lr 变化。6.4 用一个小配置先把流程跑通我第一次跑通这个作业用的是字符级 tokenizer语料是 wikitext-2 里的一小部分词表大约几百模型用的是单层 transformer 或者更小的 MLP配置大概是batch_size8, seq_len128, d_model64, lr3e-4。跑起来后loss 从接近ln(vocab_size)开始慢慢下降。跑通之后我再加了一步写一个采样函数看看模型能不能生成像样的文本。最简采样就是每次把当前序列喂给模型取最后一个位置的 logits按 temperature 采样torch.no_grad() def generate(model, tokenizer, start_ids, max_new_tokens50, temperature1.0): model.eval() ids start_ids[:] for _ in range(max_new_tokens): input_ids torch.tensor([ids[-128:]], devicenext(model.parameters()).device) logits model(input_ids)[0, -1] / temperature probs torch.softmax(logits, dim-1) next_id torch.multinomial(probs, num_samples1).item() ids.append(next_id) return tokenizer.decode(ids)这一步非常关键因为它能让你直观看到训练循环是否真的“学会”了。如果生成出来全是unk或者一串无法理解的字符说明数据预处理或采样逻辑里有问题。我见过有人训练 loss 降得不错但生成结果一塌糊涂最后发现是 tokenizer 的 decode 少处理了 special token。7. 常见问题与排查技巧实录这个作业的坑很大概率会落在下面几个地方。我把它们整理成一张速查表再展开说明。问题常见原因排查方向loss 为 NAN学习率太大、梯度爆炸降低 lr加梯度裁剪检查输入是否含 NaNloss 下降很慢labels 位移错误、padding 未 mask检查 input_ids 和 labels 是否错开一位loss 一开始就很低模型看到未来信息、数据泄漏检查 causal mask 是否存在验证集 loss 低于训练集dropout 影响训练阶段、数据分布差异在 eval 模式跑验证集训练中显存 OOMbatch_size 或 seq_len 太大减小 batch/seq_len加梯度累积checkpoint 加载报错参数名或配置不一致保存和加载时都带上 config7.1 loss 完全不降怎么办先看初始 loss 是否接近ln(vocab_size)。如果不是说明计算图里有问题。如果初始值正常但就是不降第一步检查 labels输入[2,5,9]对应标签是不是[5,9,3]。第二步检查 optimizer确保optimizer.step()真的更新了参数可以用一个临时办法训练几步后打印第一层 embedding 权重范数看有没有变化。第三步检查 loss 是否被 mask 正确忽略如果 padding token 参与计算模型可能一直在学预测pad真实 token 的学习信号会被稀释。7.2 显存 OOM 怎么查OOM 最直接的解法是调小batch_size和seq_len。但要注意调小 batch_size 会让梯度估计更不稳可以配合梯度累积。还有一个冷门技巧把无需梯度的位置用torch.no_grad()包起来。但语言模型训练一般全图都要梯度所以最实用的还是“小 batch 梯度累积”组合。另外你在 CPU 上跑小规模实验时不需要担心显存但一旦切到 GPU就必须关注seq_len。序列长度对显存影响是线性的但对 attention 来说计算量是平方增长。如果 OOM 发生在 forward优先减seq_len如果发生在 backward优先减batch_size。7.3 为什么验证集 loss 比训练集低很多人遇到这种情况会觉得是不是泄露了数据其实最常见的解释是训练阶段开了 dropout。训练时 dropout 会随机丢弃神经元让模型表达能力受限loss 偏高验证集上 dropout 被关闭模型表现反而更稳定loss 更低。这不是 bug是 dropout 的正常行为。另一种可能是数据划分有问题验证集文本比训练集更短或者验证集属于同主题、重复度过高。如果你怀疑这点可以随机抽样几条验证集样本看内容分布。CS336 课程里一般会提供标准划分直接用就行。7.4 采样结果和 loss 对不上这是最让人崩溃的。loss 明明降得不错但生成文本毫无逻辑。通常问题出在采样方式和训练数据不一致。比如你训练时用的是 BPE采样时 decode 错误或者你在采样时没有限制最大长度模型生成到某个 token 后进入死循环。排查方法很简单先不采样而是拿训练集里的一条文本把前几个 token 喂给模型看它能不能预测出接近真实的 next token。如果预测完全随机那训练循环还是有问题如果预测不错那问题大概率在 decode 或采样流程。8. 作业之外训练循环延伸到的真实世界做完 Assignment 1你会发现自己已经不只是在写一个“作业”而是在重复很多大语言模型工程里最基本的工作。那些真正的大模型训练流程不会在训练循环上换一套魔法而是在这个循环外面加更多工程。比如分布式数据并行、模型并行、混合精度、通信压缩、断点续训、实验追踪。8.1 从“本地可跑的训练循环”到“大语言模型训练流程图”如果你把 Assignment 1 的代码扩写一下其实就是一张大语言模型训练流程图数据管道、tokenizer、batch sampler、模型前向、loss、反向、梯度同步、优化器更新、checkpoint。大模型训练只是把这个流程横着复制到多张 GPU 上再在每个环节加一些并行策略。所以每当你看到某个公司的训练流程图先不要觉得遥不可及。你写过的那个小循环就是整张图的中心。后面加的东西都是围绕这个中心做性能优化和稳定性保障。8.2 预训练、分类、评估和 API 部署的关系Assignment 1 做出来的是一个“预训练语言模型”的小小雏形。它的核心是让模型在大规模文本上学会预测下一个 token。有了这个基础后面才能做分类、微调、评估、部署。所谓“语言模型分类”很多时候不是让模型做文本分类而是把语言模型按训练目标分成因果、掩码、seq2seq 等类型Assignment 1 里你训练的是因果模型这个分类你会在后面的课程里反复用到。如果以后你想把训练好的模型做成服务那就进入“本地部署大语言模型”的范畴了。这时候你需要处理的不只是训练循环还有模型格式转换、推理优化、服务端 API 的 URL 地址如何暴露、请求鉴权、并发控制等等。但这些都不是 train loop 能替代的。训练循环是起点模型从你手里交付出去之前首先要保证它是真的训练好了。8.3 做完这个作业最该带走的东西我个人觉得CS336 Assignment 1 最有价值的地方不是让你知道语言模型怎么训练而是让你拥有一种“肌肉记忆”看到 loss 异常时你能第一时间想到去看数据、看 mask、看 labels、看学习率而不是盲目换模型。等你以后真的去训练更大的模型这种第一反应比任何现成框架都值钱。我现在做实验就算会用 Trainer也习惯先看一眼配置里数据预处理和 loss 的 mask 逻辑。很多线上 bug 不是模型写错了而是数据处理和 loss 计算之间对不上。如果你能把 Assignment 1 真正写明白后面遇到这类问题基本半个小时内就能定位。