"""Render a static, inspectable report from complete machine-readable results."""
import html,json
from pathlib import Path
from experiment import ROOT
r=ROOT/'results'
summary=json.loads((r/'summary.json').read_text())
tags=['baseline','explained-seed17','answer_only-seed17','explained-seed29','answer_only-seed29']
names={'baseline':'原始指令模型','explained-seed17':'保留解释 · 17','answer_only-seed17':'只留结论 · 17','explained-seed29':'保留解释 · 29','answer_only-seed29':'只留结论 · 29'}
data={t:json.loads((r/f'{t}.json').read_text())['records'] for t in tags}
by_tag={x['tag']:x for x in summary['models']}
E=html.escape
rows='';taskrows=''
for x in summary['models']:
 a,b=x['within_template'],x['probe'];train=x.get('training',{})
 rows+=f'<tr><th scope="row">{names[x["tag"]]}</th><td>{a["correct"]} / 36</td><td>{b["correct"]} / 12</td><td>{a["unparsed"]+b["unparsed"]}</td><td>{a["hit_limit"]+b["hit_limit"]}</td><td>{train.get("supervised_tokens","—")}</td></tr>'
 taskrows+=f'<tr><th scope="row">{names[x["tag"]]}</th>'+''.join(f'<td>{x["tasks"][t]["correct"]} / 12</td>' for t in ['discount','inventory','percentage'])+'</tr>'
pairtext=''
for p in summary['paired_disagreements']:
 a=[v for v in p['explained_only_correct'] if not v.startswith('probe')]
 b=[v for v in p['answer_only_correct'] if not v.startswith('probe')]
 pairtext+=f'<li>种子 {p["seed"]}：有 {len(a)} 道同模板题只有保留解释组答对；也有 {len(b)} 道只有只留结论组答对。</li>'
extra=''
post=r/'baseline-512-posthoc.json'
if post.exists():
 d=json.loads(post.read_text());a=[x for x in d['records'] if x['task']!='probe'];b=[x for x in d['records'] if x['task']=='probe']
 extra=f'<p>发现这个问题后，另做了一次<strong>事后诊断</strong>：仅将原模型的输出上限改为 512，重新生成全部 48 题，不训练、不改提示词和解析器。严格得分变为同模板 {sum(x["correct"] for x in a)}/36、探针 {sum(x["correct"] for x in b)}/12；未解析 {sum(x["parsed"] is None for x in d["records"])}/48，达到长度上限 {sum(x["hit_limit"] for x in d["records"])}/48。<a href="baseline-512-posthoc.json">原始记录</a>与<a href="../check_length.py">脚本</a>单独保存。这只是检查基线对长度限制的敏感性，没有替换主实验，也不是新的五组同条件比较。</p><p>放宽长度后，格式仍然影响严格得分。例如 percentage-700-20，原模型最终答出正确的 672，却没有使用 Answer: 格式，所以依然未通过。这再次说明：表里的严格通过数不等同于内容正确数。</p>'
def response(t,id):return next(x for x in data[t] if x['id']==id)
caseids=['inventory-8-6-8','discount-200-20-220']
cases=''
for id in caseids:
 a=response('explained-seed17',id);b=response('answer_only-seed17',id)
 cases+=f'<section class="case"><h3>{id} · 正确值 {a["expected"]}</h3><p>{E(a["question"])}</p><p class="label">保留解释 · 种子 17</p><blockquote>{E(a["output"])}</blockquote><p class="label">只留结论 · 种子 17</p><blockquote>{E(b["output"])}</blockquote></section>'
examples=''
for i,item in enumerate(data['baseline']):
 diff=any(data[f'explained-seed{s}'][i]['correct']!=data[f'answer_only-seed{s}'][i]['correct'] for s in [17,29])
 examples+=f'<details class="sample" id="{item["id"]}" data-task="{item["task"]}" data-diff="{str(diff).lower()}"><summary>{item["id"]} <span>· 正确值 {item["expected"]}</span></summary><p>{E(item["question"])}</p>'
 for tag in tags:
  x=data[tag][i]
  outcome='数值匹配' if x['correct'] else ('未解析' if x['parsed'] is None else '数值不匹配')
  suffix=' · 达到长度上限' if x['hit_limit'] else ''
  examples+=f'<h3>{names[tag]} · {outcome} · {x["generated_tokens"]} tokens{suffix}</h3><pre>{E(x["output"])}</pre>'
 examples+='</details>'
rawlinks=' · '.join(f'<a href="{tag}.json">{names[tag]}</a>' for tag in tags)
counts=[by_tag[t]['within_template']['correct'] for t in tags[1:]]
intro=f'在这次 0.5B 模型的小实验里，保留解释组的同模板测试得分为 {counts[0]}/36 和 {counts[2]}/36，只留结论组为 {counts[1]}/36 和 {counts[3]}/36。这里只报告这批题上的结果。样本很少，解释组接受的监督 token 约为另一组的 5.9 倍，不能据此证明“删解释会让模型普遍失去推理能力”。'
style='''*{box-sizing:border-box}html{scroll-behavior:smooth}body{font:16px/1.85 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#30343b;margin:0;background:#fff}main{max-width:980px;margin:auto;padding:40px 28px 70px}a{color:#1766aa;text-underline-offset:4px}a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #1766aa;outline-offset:4px}nav{display:flex;flex-wrap:wrap;gap:20px;font-size:14px;margin-bottom:36px}h1{font-size:clamp(27px,4.5vw,36px);line-height:1.45;letter-spacing:-.025em;color:#181b20;margin-bottom:12px}h2{font-size:23px;line-height:1.5;margin:38px 0 14px}h3{font-size:16px}.meta{color:#69717c;font-size:14px}.lead{font-size:18px;line-height:1.9}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border-bottom:1px solid #dde3e9;padding:12px;text-align:left;white-space:nowrap}thead th{background:#f5f7fa}tbody th{font-weight:500}.note{border-left:3px solid #d9b582;padding:1px 0 1px 18px;margin:22px 0}.case{margin:24px 0;padding:18px 22px;border:1px solid #e5eaf0;border-radius:4px}.case h3{margin-top:0}.label{font-size:13px;color:#69717c;margin-bottom:6px}blockquote{margin:0 0 16px;padding:8px 16px;background:#f6f8fa;overflow-wrap:anywhere}.sample{border-bottom:1px solid #dde3e9;padding:15px 0}.sample summary{cursor:pointer;font-weight:600;padding:4px 0}.sample summary span{font-weight:400;color:#69717c}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;padding:16px;font:14px/1.7 ui-monospace,monospace}li{margin:8px 0}.controls{display:flex;flex-wrap:wrap;gap:14px;align-items:center;padding:16px;background:#f5f7fa;border-radius:4px;font-size:14px}select{font:inherit;padding:6px;background:white;border:1px solid #ccd3dd;border-radius:3px}input{accent-color:#1766aa}footer{border-top:1px solid #dde3e9;margin-top:40px;padding-top:20px;font-size:13px;color:#69717c}[hidden]{display:none!important}@media(max-width:600px){main{padding:24px 18px 50px}.lead{font-size:17px}th,td{padding:10px}h2{font-size:21px}.case{padding:14px}nav{margin-bottom:28px}}@media print{.controls,nav{display:none}main{padding:0;max-width:none}body{font-size:11pt}}'''
doc=f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>保留解释与只留结论：一次小规模 SFT 对照 · Nate Zhang</title><meta name="description" content="96 道构造训练题、两个随机种子的 LoRA 对照，附完整训练脚本、逐题输出，以及格式与截断问题的分析。"><link rel="canonical" href="https://qiangzhang-dev.github.io/experiments/sft-explanation/results/"><style>{style}</style></head><body><main><nav aria-label="页面导航"><a href="/">Nate / 个人网站</a><a href="/notes/sft-rewrite-review/">训练答案改写检查</a><a href="https://github.com/qiangzhang-dev/qiangzhang-dev.github.io/tree/main/experiments/sft-explanation">代码与数据 ↗</a></nav><article><h1>保留解释与只留结论：<br>一次小规模 SFT 对照</h1><p class="meta">2026-09-29 · Qiang (Nate) Zhang · Qwen2.5-0.5B-Instruct / LoRA</p><aside class="note"><p><strong>后续复核：</strong>同一批 512-token 原模型输出，按最终数值计为 17/36，旧格式评分为 2/36。换表述后，保留解释组从 32/36、35/36 都降到 17/36，只留结论组从 16/36、17/36 降到 10/36、5/36。固定模板上的高分未完整保留。<a href="../followup/">查看后续报告与配对记录 →</a></p><p class="meta">首轮原始输出和本页首轮评分保留。</p></aside><p class="lead">{intro}</p><h2>两组到底改了什么</h2><p>同一批 96 道英文训练题，两组最终数值都正确。一组保留计算步骤或条件判断，一组只有 <code>Answer: 数值.</code>。提示词要求以 Answer 数值收尾，没有要求必须解释或必须简答。</p><p>题目包括数量计算、排除运费的满减门槛、先增后减的百分比变化，每类 32 道。测试使用相同模板下未参与训练的 36 个参数组合，另加 12 道简单事实与算术探针。这不是新题型泛化测试，也不是通用能力基准。</p><p>每个随机种子都从同一个原始模型和新初始化的 LoRA 开始，训练三轮、每批 4 条，共 72 步；两组配对使用相同的样本顺序。只监督 assistant 部分，训练不截断。固定取最后一轮，没有依据测试分数挑选检查点。完整设置见<a href="../protocol.json">实验协议</a>。</p><h2>先看两个训练组</h2><div class="table-wrap"><table><caption class="meta">主实验：greedy，最多生成 128 tokens；每个种子分别列出</caption><thead><tr><th>模型 / 种子</th><th>同模板 / 36</th><th>探针 / 12</th><th>未解析 / 48</th><th>截断 / 48</th><th>监督 tokens</th></tr></thead><tbody>{rows}</tbody></table></div><p>这里的“通过”要求明确的 Answer 数值正确；没有解析到，或多个 Answer 数值冲突，都记为未通过。表中的原始模型得分尤其需要结合下文的格式与长度问题阅读。</p><div class="table-wrap"><table><caption class="meta">同模板题按任务拆开，每类 12 道</caption><thead><tr><th>模型 / 种子</th><th>满减条件</th><th>数量计算</th><th>百分比</th></tr></thead><tbody>{taskrows}</tbody></table></div><ul>{pairtext}</ul><p>两个种子在同一批题上测试，不能把它们合并成两倍的独立测试样本。12 道探针中一题就占 8.3 个百分点，适合发现具体问题，不适合给“能力保持”下结论。</p><h2>解释更长，也可能写错</h2><p>下面两题都来自种子 17。第一题，解释组完成了乘法后的减法；第二题，解释组把大小关系写反了，只留结论组反而答对。它们说明为什么需要看逐题输出。</p>{cases}<h2>原模型的 0/36，不等于一道都不会算</h2><div class="note"><p>原模型在主实验的 36 道同模板题上没有通过严格评分，但其中 23 道没有解析出 Answer 数值，17 道达到 128-token 上限。这个分数包含格式要求和生成预算的影响，不能把它解读成微调带来了从零开始的推理能力。</p></div><p>例如 <a href="#inventory-8-11-2">inventory-8-11-2</a>，原模型已经算出 8 × 11 − 2 = 86，并把 86 放进了公式框；因为没有输出 Answer: 86，仍被记为未通过。百分比题则有 12/12 碰到了长度上限。因此，这里的主要比较是两种训练目标之间的表现，不是用这个基线制造“巨大提升”。</p>{extra}<h2>这次能留下什么结论</h2><p>这组小实验观察到了不同训练答案带来的结果差异，也暴露了评测实现对结论的影响。它还不足以区分：差异来自中间步骤、任务信息重复、监督 token 预算，还是这些因素共同作用。</p><p>两组虽然都训练 72 步，监督 token 分别是 13,974 和 2,349；损失又是在每批有效 token 上取平均。解释组不仅“多说了几句”，还多接受了任务相关监督。数据使用固定模板、英文、小模型和两个种子，也都限制了外推范围。这里没有测量口语自然度，更没有测试语音模型或真实业务系统。</p><p>下一轮最值得先改的是评测：让生成预算足够、将格式遵循与答案内容分开报告，再用不同表述和题型检验能否迁移。确认评测口径后，才适合进一步比较监督预算。</p><section id="split-audit-2026-10-01"><h2>2026-10-01 补充：按决策条件检查数据划分</h2><p>本次只复核已公开的数据与输出，没有追加训练或推理。满减题的答案由商品小计和满减门槛决定，运费不参与判断。按这两个量分组，32 条训练样本对应 20 组条件，12 条测试样本对应 9 组；其中 8 条测试样本的条件组合已在训练中出现，另外 4 条对应 3 组未见组合。完整题干和完整参数组合仍不重复，但它们不都代表新的决策条件。保留解释的两个模型在这 4 条未见组合的原题上分别答对 3 条和 4 条；样本太少，且有条件重复，只能作事后描述，不能据此证明记忆或解释训练机制，也不能把它当作新的独立基准。原协议、数据、输出和主结果保持不变。</p><p>复核使用 <a href="../split_audit.py">只读审计脚本</a>和<a href="../split_audit.json">逐条对应关系、结果与来源指纹</a>；来源固定到 <a href="https://github.com/qiangzhang-dev/qiangzhang-dev.github.io/commit/8ff7032e8deceffad2d368ea844160cdfe315608">本次复核前的公开版本</a>。如果今后另做数据划分，应先按商品小计和满减门槛分组，再分配训练集与测试集。</p></section><h2>复现与原始记录</h2><ul><li><a href="../README.md">运行说明</a> · <a href="../experiment.py">训练与推理脚本</a> · <a href="../check_inputs.py">数据与标签检查</a></li><li><a href="../data/train.json">96 道训练题</a> · <a href="../data/test.json">48 道测试与探针题</a> · <a href="../model-manifest.json">模型文件校验</a> · <a href="../requirements-lock.txt">环境版本</a></li><li><a href="summary.json">结果汇总 JSON</a> · <a href="../summarize.py">统计脚本</a> · <a href="../render_report.py">本页生成脚本</a></li><li>完整逐题 JSON：{rawlinks}</li></ul><p class="meta">公开模型与程序构造数据；CPU 实际训练和推理。原始 128-token 结果保留不变，后续诊断单独记录。基础模型：<a href="https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct">Qwen2.5-0.5B-Instruct</a>，版本固定在协议中。</p><h2 id="all-cases">全部 48 道题</h2><p>展开可看原模型及四次训练后的原始回答。可以按题型筛选，或只看同一种子下两组评分不同的题。</p><div class="controls"><label>题型 <select id="task"><option value="all">全部</option><option value="discount">满减条件</option><option value="inventory">数量计算</option><option value="percentage">百分比</option><option value="probe">额外探针</option></select></label><label><input type="checkbox" id="diff"> 只看两组评分不同的题</label><output id="count" aria-live="polite">48 / 48 道</output></div><div id="samples">{examples}</div></article><footer>Qiang (Nate) Zhang · <a href="/">个人网站</a> · <a href="#all-cases">逐题记录</a></footer></main><script>const task=document.querySelector('#task'),diff=document.querySelector('#diff');function filter(){{let n=0;document.querySelectorAll('.sample').forEach(el=>{{el.hidden=!((task.value==='all'||el.dataset.task===task.value)&&(!diff.checked||el.dataset.diff==='true'));if(!el.hidden)n++}});document.querySelector('#count').textContent=n+' / 48 道'}}task.addEventListener('change',filter);diff.addEventListener('change',filter);</script></body></html>'''
(r/'index.html').write_text(doc)
print('Report rendered:',len(doc.encode()),'bytes')
