Files
econometrics/Week7/.week7_code_explanation/build_explanation.py
T

663 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Create an offline, line-accurate Chinese guide without changing analysis inputs.
Run from any directory: python <this file>
Dependencies: numpy, scipy, openpyxl (independent numerical checks only).
"""
from pathlib import Path
import base64
import csv
import hashlib
import html
from html.parser import HTMLParser
import json
import re
import numpy as np
from openpyxl import load_workbook
from scipy import stats
ASSETS = Path(__file__).resolve().parent
ROOT = ASSETS.parent
MD_PATH = ROOT / 'Week7_Stata代码详解.md'
HTML_PATH = ROOT / 'Week7_Stata代码详解.html'
DATE = '2026-10-26'
SOURCE = ROOT / 'week7_analysis.do'
source_bytes = SOURCE.read_bytes()
source = source_bytes.decode('utf-8-sig')
lines = source.splitlines()
assert len(lines) == 165
def read_csv(name):
with (ROOT / name).open(encoding='utf-8-sig', newline='') as stream:
return list(csv.DictReader(stream))
def numeric(row):
return {key: float(value) for key, value in row.items()}
model = numeric(read_csv('model_statistics.csv')[0])
coefs = {row['term']: numeric({k: v for k, v in row.items() if k != 'term'})
for row in read_csv('regression_coefficients.csv')}
robust = {row['term']: numeric({k: v for k, v in row.items() if k != 'term'})
for row in read_csv('robust_coefficients.csv')}
audit = [numeric(row) for row in read_csv('source_totals_audit.csv')]
groups = [numeric(row) for row in read_csv('income_group_summary.csv')]
clean = [numeric(row) for row in read_csv('households_clean.csv')]
fitted = [numeric(row) for row in read_csv('households_fitted.csv')]
# Reconstruct the exact 98 records from Excel rather than trusting previous reports.
wb = load_workbook(ROOT / 'Eg2_1_1/Eg2_1_1.xlsx', data_only=True, read_only=True)
ws = wb['Sheet1']
records = []
for col, income in enumerate(range(800, 3501, 300), start=3):
assert ws.cell(3, col).value == income
values = []
for row in range(4, 17):
value = ws.cell(row, col).value
if value is not None:
records.append((row, income, float(value)))
values.append(float(value))
a = audit[col - 3]
assert (a['income'], a['n'], a['detail_total'], a['reported_total'], a['difference']) == (
income, len(values), sum(values), ws.cell(17, col).value,
sum(values) - ws.cell(17, col).value)
wb.close()
assert len(records) == len(clean) == len(fitted) == 98
assert len(groups) == len(audit) == 10
for index, record in enumerate(records, start=1):
for table in (clean, fitted):
observed = table[index - 1]
assert observed['household_id'] == index
assert tuple(observed[k] for k in ('excel_row', 'income', 'consume')) == record
array = np.asarray(records)
x, y = array[:, 1], array[:, 2]
X = np.column_stack((np.ones(len(x)), x))
beta = np.linalg.lstsq(X, y, rcond=None)[0]
pred = X @ beta
resid = y - pred
n, k = X.shape
df = n - k
rss = resid @ resid
inv = np.linalg.inv(X.T @ X)
cov = rss / df * inv
hc1 = n / df * inv @ (X.T @ (X * (resid ** 2)[:, None])) @ inv
se = np.sqrt(np.diag(cov))
se_hc1 = np.sqrt(np.diag(hc1))
r2 = 1 - rss / ((y - y.mean()) @ (y - y.mean()))
F = ((y - y.mean()) @ (y - y.mean()) - rss) / (rss / df)
crit = stats.t.isf(.025, df)
for i, term in enumerate(('_cons', 'income')):
for output, std_error in ((coefs[term], se[i]), (robust[term], se_hc1[i])):
tval = beta[i] / std_error
expected = [beta[i], std_error, tval, 2 * stats.t.sf(abs(tval), df),
beta[i] - crit * std_error, beta[i] + crit * std_error]
actual = [output[key] for key in ('estimate', 'se', 't', 'p', 'ci_low', 'ci_high')]
np.testing.assert_allclose(actual, expected, rtol=1e-9, atol=0)
np.testing.assert_allclose([model[key] for key in ('intercept', 'slope', 'r_squared', 'root_mse', 'rss', 'F', 'p_F')],
[*beta, r2, np.sqrt(rss / df), rss, F, stats.f.sf(F, 1, df)], rtol=1e-9, atol=0)
assert model['N'] == n and model['df_resid'] == df
np.testing.assert_allclose([row['fitted'] for row in fitted], pred, rtol=1e-10, atol=1e-8)
np.testing.assert_allclose([row['residual'] for row in fitted], resid, rtol=1e-10, atol=1e-8)
np.testing.assert_allclose([row['residual_sq'] for row in fitted], resid ** 2, rtol=1e-9, atol=1e-8)
assert abs(model['residual_sum']) < 1e-7 and abs(model['income_residual_sum']) < 1e-4
for group in groups:
yy = y[x == group['income']]
expected = [len(yy), yy.mean(), yy.std(ddof=1), yy.min(), yy.max(),
beta[0] + beta[1] * group['income'], yy.mean() - beta[0] - beta[1] * group['income']]
np.testing.assert_allclose([group[key] for key in ('n', 'mean_consume', 'sd_consume', 'min_consume', 'max_consume', 'fitted_mean', 'mean_residual')],
expected, rtol=1e-10, atol=1e-8)
# Stata 15's default normal BP/CW score statistic = auxiliary model SS / 2.
scaled_sq = resid ** 2 / (rss / n)
Z = np.column_stack((np.ones(n), pred))
aux_pred = Z @ np.linalg.lstsq(Z, scaled_sq, rcond=None)[0]
bp = ((aux_pred - scaled_sq.mean()) @ (aux_pred - scaled_sq.mean())) / 2
np.testing.assert_allclose([model['bp_chi2'], model['bp_p']], [bp, stats.chi2.sf(bp, 1)], rtol=1e-9)
log = (ROOT / 'week7_stata.log').read_text(encoding='utf-8')
assert re.search(r'^WEEK7_ANALYSIS_COMPLETED\s*$', log, re.M)
assert not re.search(r'^r\(\d+\);', log, re.M)
assert '23 Oct 2026' in log
sections = []
covered = set()
def section(anchor, title, start=None, end=None):
item = {'id': anchor, 'title': title, 'range': (start, end) if start else None, 'blocks': []}
sections.append(item)
if start:
item['blocks'].append(('code', '\n'.join(lines[start - 1:end]), start, 'stata'))
covered.update(range(start, end + 1))
return item
def paragraph(text):
sections[-1]['blocks'].append(('p', text))
def table(headers, rows):
sections[-1]['blocks'].append(('table', headers, rows))
def note(title, text):
sections[-1]['blocks'].append(('note', title, text))
def formula(text):
sections[-1]['blocks'].append(('formula', text))
def code(text, language='stata'):
sections[-1]['blocks'].append(('code', text, None, language))
section('overview', '阅读地图:这份 do 文件完成什么')
paragraph('这是一份从 Excel 家庭消费明细出发的完整 Stata 分析脚本:核对源表 → 转成长表 → 估计消费与收入的 OLS 关系 → 保存拟合值与残差 → 分组汇总与绘图 → 计算稳健标准误及异方差检验 → 导出结果。回归单位是 98 条家庭记录,收入有 10 个水平。')
paragraph('本文编写和独立核对日期为 **2026 年 10 月 26 日**;所引 Stata 数值来自工作目录内 **2026 年 10 月 23 日**的运行日志及 CSV。此次没有重跑或改动原 do 文件,也没有改写既有分析产物。我们从原 Excel 独立复算了主要统计量,确认它们与现有输出一致。下文代码块直接截取原文件,L 表示原文件物理行号。')
formula(f'拟合消费 = {beta[0]:.6f} + {beta[1]:.9f} × 月收入;N = 98;R² = {r2:.6f}')
table(['输入 / 输出角色', '内容'], [
['原始输入', '`Eg2_1_1/Eg2_1_1.xlsx`,工作表 `Sheet1`,区域 `C3:L17`。'],
['被解释变量 Y', '`consume`:每月消费支出,单位元。'],
['解释变量 X', '`income`:每月可支配收入,单位元。'],
['主要文件', 'DTA 保存数据,CSV 便于通用读取,STER 保存估计结果,GPH/PNG/SVG/PDF 保存图形,LOG 保存运行过程。'],
['软件与版本', '用户提供的可执行文件为 `D:/Stata15/StataSE-64.exe`;既有批处理日志显示 Stata/SE 15.1,脚本用 `version 15.0` 指定兼容行为。'],
])
note('先抓住三个区别', 'Excel 行号不等于导入后的 Stata 观测号;`preserve/restore` 管数据,而 `estimates store/restore` 管估计结果;`vce(robust)` 改变标准误和推断,当前模型的 OLS 系数与拟合值保持相同。')
section('syntax', '语法预备:宏、变量、标量与返回值')
table(['写法', '在本脚本中的含义'], [
["`local income = 800` / `` `income' ``", 'local 定义局部宏;左反引号与右单引号取出宏内容。例如循环首轮 income 宏展开为 800。它不是数据列。'],
["`` `col'[1] ``", '若 col 宏是 C,展开为 C[1]:C 变量的第 1 条观测。方括号是观测下标。'],
["`` consume`income' ``", '拼接变量名:首轮得到 consume800,下一轮得到 consume1100。'],
['`generate income = ...` / `income`', 'generate 创建数据列;reshape 后的 income 是每条记录都有的数值变量。宏与同名变量用引用语法区分。'],
['`scalar slope_ols = ...`', '保存一个数值;`scalar(slope_ols)` 明确取标量,可避免与变量名混淆。'],
['`r(N)`、`r(sum)`、`r(varlist)`', '一般统计或数据命令返回的结果。summarize 返回 N 和 sum;ds 返回变量名列表。后续 r-class 命令可能覆盖 r(),所以及时保存。'],
['`e(N)`、`e(r2)`、`e(df_r)`', '当前估计命令返回的结果;下一次 regress 会改写当前 e()。'],
['`_b[income]`、`_se[income]`、`_cons`', '当前模型中 income 的系数、标准误,以及截距项的名字。'],
['`_n` 与 `_N`', '分别是当前观测的序号、当前数据集观测总数;删行和排序后含义随当前数据变化。'],
['`=`、`==`、`!`、`^`', '分别常用于赋值/表达式计算、相等比较、逻辑非、乘方。`assert _N == 98` 是检验,不是赋值。'],
['逗号后的内容', '一般是命令选项,例如 `regress consume income, vce(robust)`。'],
['`quietly` / `capture`', 'quietly 隐藏普通输出但仍计算并返回结果;capture 捕获错误,允许程序继续。本文件只在关闭旧日志时使用 capture。'],
['`replace`', '在 save/export/log 等命令中表示覆盖同名文件;它不同于单独的 replace 命令修改变量值。'],
['`tempname`', '为文件句柄、标量或矩阵等对象取得唯一临时名称。本文件把名称放进局部宏,交给 postfile 使用;它不让指定的 DTA 文件变成临时文件。'],
])
paragraph('局部宏应连同定义和使用它的代码一起执行;在编辑器中分开执行几段,可能丢失上一段的局部宏上下文。代码中的单引号也不要替换成中文弯引号。语法依据:本机官方帮助 [S1]、[S2]、[S5]。')
section('setup', '01 · 初始化环境与记录日志', 1, 8)
table(['行号 / 命令', '解释'], [
['L1 · `version 15.0`', '按 Stata 15.0 的版本兼容规则解释命令;不是安装或切换可执行软件版本。'],
['L2 · `clear all`', '清理当前内存数据和估计等工作状态,为整段分析建立干净起点。'],
['L3 · `set more off`', '关闭长输出的分页暂停,便于批量运行。'],
['L4 · `set type double`', '未显式指定类型的新数值变量默认使用 double;不等于把所有已有或导入变量自动转成 double。后面的 int 仍按显式类型创建。'],
['L5 · `set linesize 160`', '设置文本输出行宽为 160,减少长表格折行;不改变数据或网页宽度。'],
['L6 · `capture log close _all`', '关闭已打开的所有日志;没有日志时产生的错误由 capture 捕获,不中断程序。'],
['L7 · `cd ...`', '切换工作目录。后续相对路径都以这里为起点;换电脑运行时须修改这个硬编码路径。'],
['L8 · `log using ...`', '把后续命令和结果记录到 week7_stata.log;text 指定纯文本,replace 覆盖同名旧日志。'],
])
paragraph('double 是存储类型,format 是显示格式,两者不可互换。增加显示位数不能恢复已经丢失的数值精度。官方帮助:[S1]、[S3]。')
section('import', '02 · 读取 Excel 的指定数值区域', 10, 12)
table(['行号 / 命令', '解释'], [
['L10–11 · `display`', '只打印分析标题和输入范围说明,不生成变量,也不验证数据。'],
['L12 · `import excel using ...`', '从相对路径的工作簿导入 Sheet1 的 C3:L17。clear 允许替换内存数据。'],
['L12 · 没有 `firstrow`', '收入数值表头仍作为第 1 条观测读入;这里变量名为 Excel 列字母 C、D、…、L,后续循环会使用这些名字。'],
])
table(['Excel 位置', '刚导入时的 Stata 观测号', '含义'], [
['C3:L3', '第 1 条', '收入水平:800、1100、1400、…、3500。'],
['C4:L16', '第 2–14 条', '消费明细,13 行 × 10 列;部分为空白。'],
['C17:L17', '第 15 条', '原表报告的合计,只用于核对。'],
])
note('不要把数字表头当成变量名', '若在这里直接加 firstrow,后续按 C–L 命名和第 1、15 条观测取值的逻辑就需要一起调整。原代码的行号依赖当前导入范围。')
paragraph('导入语法与 firstrow 行为依据本机官方帮助 [S4];具体单元格结构已从当前工作簿核对。')
section('audit', '03 · 逐列检查收入表头并核对合计', 14, 27)
table(['行号 / 命令', '解释'], [
['L14 · `tempname audit`', '创建唯一的临时句柄名,放在局部宏 audit 中,避免与已有对象重名。'],
['L15 · `postfile ... using ...`', '声明新的审计结果表:income 收入、n 非缺失条数、detail_total 明细和、reported_total 原表合计、difference 差额;n 用 int,其余字段按声明使用 double。不会替换当前 Excel 数据。'],
['L16–17 · `local` + `foreach`', '从收入 800 开始,按 C 到 L 的顺序循环 10 次。每轮 col 宏代表一个列名。'],
['L18 · `assert`', '确认当前列第 1 条观测等于预期收入。若表头或列顺序不符,断言失败,脚本停止。'],
['L19 · `quietly summarize ... in 2/14`', '只汇总明细所在的第 2–14 条观测;缺失值不参加计数和求和;quietly 仅隐藏输出。'],
['L20–22 · 三个 `local`', '保存 r(N)、r(sum) 和第 15 条观测中的原表合计,以免之后命令覆盖 r()。'],
['L23 · `post`', '按声明顺序写入一条审计记录。差额 = 明细之和 − 原表合计;每个待写表达式分别放在括号中。'],
['L24 · `rename`', '例如把 C 改名为 consume800,把 D 改名为 consume1100,为 reshape 的共同前缀 consume 做准备。'],
['L25–26 · 收入递增、结束循环', '收入宏每轮增加 300,与下一个列的收入表头对应。最后一轮处理 consume3500。'],
['L27 · `postclose`', '完成写入并关闭句柄,使 source_totals_audit.dta 可被后续 use 读入。'],
])
paragraph('首轮循环可读成:检查 C[1] = 800 → 统计 C[2] 到 C[14] → 读取 C[15] → 写入审计表 → 把 C 改名为 consume800 → 把收入宏改成 1100。postfile/post/postclose 的行为依据 [S2],summarize 返回值依据 [S5]。')
table(['收入', '有效明细数', '明细之和', '原表合计', '差额'], [
[f"{a['income']:.0f}", f"{a['n']:.0f}", f"{a['detail_total']:.0f}", f"{a['reported_total']:.0f}", f"{a['difference']:+.0f}"] for a in audit
])
note('审计记录差异,但不会自动修正', '有 4 组合计不一致:2300、2900、3200、3500 元组的差额分别为 −2002、−88、−132、−11 元。代码没有 assert difference == 0,因此发现差异后继续分析明细;它既不按合计反推缺失值,也不把合计行加入回归。')
section('export-audit', '04 · 临时切换数据并导出审计表', 28, 34)
table(['行号 / 命令', '解释'], [
['L28 · `preserve`', '暂存当前已改名的 Excel 宽表。'],
['L29 · `use ... , clear`', '把内存数据切换成刚生成的审计 DTA 文件。'],
['L30 · `list, noobs separator(0)`', '列出审计表;noobs 不显示观测序号,separator(0) 不插入分隔线。'],
['L31 · `quietly ds, has(type numeric)`', '找出数值型变量,将变量名列表放在 r(varlist) 中。'],
["L32 · ``format `r(varlist)' %21.15g``", '给这些变量设置宽度 21、精度参数 15 的通用数值显示格式 g;必要时使用科学计数法,不是固定 15 位小数。'],
['L33 · `export delimited ... , datafmt replace`', '导出 CSV。datafmt 使用刚设定的显示格式;replace 覆盖同名文件。'],
['L34 · `restore`', '恢复 L28 时保存的宽表;之后继续处理家庭明细。'],
])
paragraph('“preserve → 切换/汇总数据 → 导出 → restore”在文件中多次出现,是 Stata 15 单数据集工作流的核心。preserve/restore 不代替 estimates store/restore;数据恢复不应被当作模型恢复。DTA 保存存储值与元数据,CSV 保存文本,显示精度可能影响 CSV 的文本表示。[S3]、[S6]')
section('reshape', '05 · 从宽表整理成 98 条家庭明细', 36, 53)
table(['行号 / 命令', '解释'], [
['L36 · `keep in 2/14`', '仅保留当前宽表第 2–14 条观测:去掉收入表头和合计行,余下 13 行。'],
['L37 · `generate int excel_row = _n + 3`', 'keep 后 _n 从 1 重新编号;加 3 后得到原 Excel 行号 4–16。这是追溯来源的行标记。'],
['L38 · `reshape long consume, i(excel_row) j(income)`', '将 consume800 至 consume3500 堆叠成长变量 consume;i() 标记原宽表行,j() 把后缀 800…3500 提取成数值变量 income。'],
['L39 · `drop if missing(consume)`', '删除没有消费明细的组合。先形成 13 × 10 = 130 条,再删除 32 条缺失记录,剩 98 条;不会删除数值为 0 的消费。'],
['L40–41 · `sort`、`generate ... household_id`', '按收入、原表行号排序,再以 _n 建立 1–98 的记录编号。编号是人为生成的,不是原始调查家庭 ID。'],
['L42 · `order ...`', '把四个变量按指定顺序排列;不改变数值。'],
['L43 · `assert _N == 98`', '检查总记录数恰好为 98;若数据版本或整理逻辑变化,会停止提醒。'],
['L44 · `assert !missing(income, consume)`', '检查每条观测的收入与消费均非缺失;missing() 在任一参数缺失时为真,! 将其取反。'],
['L45–48 · `label variable`', '设置四个变量的说明标签;变量名仍是 income、consume 等。DTA 保存标签,普通 CSV 不保留完整 Stata 元数据。'],
['L49 · `save ... , replace`', '保存清洗后的 DTA;此时尚未生成拟合值、残差。'],
['L50–52 · `ds`、`format`、`export delimited`', '复用数值变量筛选和高精度 CSV 导出步骤。缩进只是排版,这几行不属于新的代码块。'],
['L53 · `summarize income consume`', '打印收入与消费的个数、均值、标准差、最小值和最大值,进行基本描述统计。'],
])
table(['原 Excel 单元格', 'reshape 前', 'reshape 后的一条记录'], [
['C4 = 561', 'excel_row=4,consume800=561', 'excel_row=4,income=800,consume=561'],
['D4 = 638', 'excel_row=4,consume1100=638', 'excel_row=4,income=1100,consume=638'],
])
note('同一 Excel 行不代表同一个家庭', '第 4 行的不同收入列属于不同家庭消费记录。因此长表中 excel_row 会重复,(excel_row, income) 才标记具体来源单元格。本脚本没有把这批数据设为面板数据,也没有做去重。相同金额可以来自不同家庭。')
paragraph(f'当前数据平均收入为 {x.mean():.4f} 元,平均消费为 {y.mean():.4f} 元。所有 98 条明细逐一与原 Excel 的收入、消费及行号一致。reshape 的 i()/j() 语义依据 [S4]。')
section('ols', '06 · OLS 回归、拟合值与模型统计量', 55, 69)
table(['行号 / 命令', '解释'], [
['L55 · `regress consume income`', '第一个变量 consume 是因变量,income 是解释变量;默认含截距,按 98 条明细等权估计 OLS。'],
['L56 · `estimates store OLS`', '把当前估计结果以 OLS 这个名字保存在内存中,供后面恢复。'],
['L57 · `estimates save ...`', '把当前估计结果写入磁盘 STER 文件。它与保存家庭数据的 save 不同,之后可用 estimates use 载入。'],
['L58 · `predict double fitted, xb`', '生成线性预测 fitted = 截距 + 收入系数 × income;xb 表示设计矩阵与系数向量的乘积。'],
['L59 · `predict double residual, residuals`', '生成残差 residual = 实际消费 − fitted。'],
['L60 · `generate double residual_sq = residual^2`', '生成残差平方,便于保留逐户误差信息。后续 estat hettest 自行从估计结果计算所需量,不直接读取这个命名变量。'],
['L61–62 · `_b[income]`、`_b[_cons]`', '分别保存斜率和截距为标量。'],
['L63–66 · `e(r2)`、`e(rmse)`、`e(rss)`、`e(N)`', '分别保存 R²、残差标准差估计(Root MSE)、残差平方和、估计样本量。'],
['L67–68 · `e(F)`、`e(df_r)`', '保存常规模型 F 统计量与残差自由度,本例自由度为 98−2=96。'],
['L69 · `Ftail(...)`', '用 F 分布右尾概率计算整体显著性 p 值;参数依次为模型自由度、残差自由度、F 值,本例为 F(1,96)。'],
])
formula('Yᵢ = β₀ + β₁Xᵢ + uᵢ;OLS 最小化 Σ(Yᵢ − β̂₀ − β̂₁Xᵢ)²。')
formula('Ŷᵢ = β̂₀ + β̂₁Xᵢ;êᵢ = Yᵢ − Ŷᵢ;RSS = Σêᵢ²;Root MSE = √[RSS/(N−2)]。')
table(['统计量', '现有输出', '如何理解'], [
['截距 / 斜率', f'{beta[0]:.6f} / {beta[1]:.9f}', '收入高 100 元,拟合消费高约 72.88 元。0 收入在样本范围外,截距不宜作实质外推。'],
['N / 残差自由度', '98 / 96', '估计了截距与一个斜率,共 2 个参数。'],
['R²', f'{r2:.9f}', '收入的线性拟合解释约 93.07% 的消费总离差平方和,不是预测正确率。'],
['Root MSE', f"{model['root_mse']:.6f} 元", '按残差自由度调整后的残差尺度;不是除以 N 的样本内预测 RMSE。'],
['RSS', f'{rss:.6f} 元²', '残差平方和。'],
['F / p_F', f"{model['F']:.6f} / {model['p_F']:.4e}", '本例整体检验是 H₀: β₁=0,不把截距同时设为 0。'],
])
paragraph('拟合值不是实际消费,也不是已知的真实总体条件均值;残差 êᵢ 是估计后可计算的量,真实扰动 uᵢ 通常不可观测。这里的收入系数描述样本中的线性关系;单变量回归本身不建立因果识别。保存标量还可防止后续稳健回归替换 e() 后丢失常规模型统计量。[S7]、[S8]')
section('coefficient-table', '07 · 手工整理系数、t 检验与置信区间', 71, 84)
table(['行号 / 命令', '解释'], [
['L71–72 · `tempname`、`postfile`', '建立系数结果表。term 用 str12 保存不超过 12 字节的短字符串变量名;其余字段 estimate、se、t、p、ci_low、ci_high 用 double。本例变量名均为 ASCII。'],
['L73 · `foreach term in income _cons`', '循环处理收入系数和截距,最终输出两行结果。'],
['L74 · `tval = _b[...] / _se[...]`', '计算检验系数等于 0 的 t 值;分子没有减去其他假设值,因此原假设是该系数为 0。'],
['L75 · `invttail(e(df_r), .025)`', '得到 t 分布右尾概率 0.025 的临界值,即双侧 95% 区间所用的 t₀.₉₇₅,₉₆;.025 不表示区间置信度为 2.5%。'],
['L76 · `post`', '依次写入变量名、系数、标准误、t 值、双侧 p 值,以及置信区间上下限。abs(tval) 取绝对值,ttail 求右尾概率,再乘 2。'],
['L77–78 · 循环结束、`postclose`', '完成两行写入,关闭系数表。'],
['L79–84 · `preserve` 至 `restore`', '暂存家庭明细,读取系数表、设置数值格式并导出 CSV,再恢复家庭明细。模型与数据是两个层面的对象。'],
])
formula('t = β̂ / SE(β̂);p = 2 × P(T₉₆ ≥ |t|);95% CI = β̂ ± t₀.₉₇₅,₉₆ × SE(β̂)。')
table(['变量', '估计系数', '常规标准误', 't', 'p', '95% 置信区间'], [
[term, f"{coefs[term]['estimate']:.6f}", f"{coefs[term]['se']:.6f}", f"{coefs[term]['t']:.4f}", f"{coefs[term]['p']:.6g}", f"[{coefs[term]['ci_low']:.6f}, {coefs[term]['ci_high']:.6f}]"]
for term in ('income', '_cons')
])
paragraph(f'临界值约为 {crit:.6f}。收入系数显著为正;截距的常规 p 值约 0.627,不能拒绝截距为 0。很小的 p 值不等于系数具有很大的经济效应。当前采用常规 OLS 标准误;有限样本精确 t/F 推断还需要相应模型条件,不能只看公式就认定条件成立。[S2]、[S7]、[S9]')
section('residual', '08 · 核对 OLS 残差的两个代数性质', 86, 97)
table(['行号 / 命令', '解释'], [
['L86–87 · `summarize residual, meanonly`', 'meanonly 省去方差计算并抑制汇总显示,但仍返回 r(sum);把残差和存入标量 residual_sum。'],
['L88–90 · 生成并汇总 `income_residual`', '计算每条观测的 income × residual,再把乘积之和保存到 income_residual_sum。'],
['L91–92 · `display ... scalar(...)`', '按 %21.12g 打印两个标量。这里的 % 格式控制显示,不改变原数值。'],
['L93 · `drop income_residual`', '删除仅用于核对的乘积变量,保留其汇总标量。'],
['L94–97 · 保存 / 导出', '把含 fitted、residual、residual_sq 的 98 条明细写入 households_fitted.dta 和 CSV。'],
])
formula('含截距 OLS 的正规方程:Σêᵢ = 0,ΣXᵢêᵢ = 0(数值计算允许微小舍入误差)。')
paragraph(f"现有输出:Σêᵢ = {model['residual_sum']:.6e},ΣXᵢêᵢ = {model['income_residual_sum']:.6e},都在浮点精度下接近 0。以 C4 为例,income=800、consume=561,拟合消费约 {pred[0]:.6f},残差约 {resid[0]:.6f} 元,表示实际消费低于拟合值。")
note('不是模型假设检验', '这两个零和性质由当前含截距 OLS 的求解方式保证,不能证明总体 E(u|X)=0,不能证明不存在遗漏变量,也不能证明同方差。它们适合检查计算过程是否一致。')
paragraph('本段 meanonly 的行为依据 [S5];残差定义及 OLS 结果依据 [S7]。上面两个等式可直接由残差平方和对截距和斜率求导得到。')
section('group', '09 · 按收入水平生成汇总表', 99, 109)
table(['行号 / 命令', '解释'], [
['L99 · `preserve`', '保存逐户明细,以便汇总后恢复。'],
['L100 · `collapse ... , by(income)`', '按收入分组,把 98 条明细替换成 10 条分组记录;(count) 数非缺失消费条数,(mean) 求均值,(sd) 求组内样本标准差,(min)/(max) 求范围。'],
['L100 · `n=consume` 等写法', '左侧是汇总后的新变量名,右侧是被汇总的原变量。例如 mean_consume=consume 生成消费均值。'],
['L101 · `recast double ...`', '保证均值、标准差的后续存储类型为 double;类型扩展不能恢复此前若已发生的精度损失。'],
['L102 · `fitted_mean`', '用此前保存的截距和斜率计算每个收入水平的拟合均值;没有重新对 10 个均值做回归。'],
['L103 · `mean_residual`', '组内实际均值减去线性拟合值。同组收入相同,所以也等于该组逐户残差的平均值。'],
['L104–108 · 保存、导出并 `list`', '将分组表保存为 DTA/CSV,并在日志中显示。'],
['L109 · `restore`', '恢复 98 条逐户明细;后面的散点图因此基于家庭记录。'],
])
table(['收入', 'n', '组内均值', '组内标准差', '拟合均值', '平均残差'], [
[f"{g['income']:.0f}", f"{g['n']:.0f}", f"{g['mean_consume']:.3f}", f"{g['sd_consume']:.3f}", f"{g['fitted_mean']:.3f}", f"{g['mean_residual']:+.3f}"] for g in groups
])
paragraph('OLS 不要求每个收入组的平均残差都等于 0;这里满足的是全样本正规方程。各组人数不同,不能用 10 个组均值的不加权回归替代当前的逐户回归。组内标准差也不是均值的标准误。collapse 命令依据 [S10]。')
section('plot', '10 · 绘制散点与拟合直线,导出四种图形', 111, 117)
table(['行号 / 选项', '解释'], [
['L111 · `set scheme s2color`', '使用 s2color 图形样式方案。'],
['L112 · `graph set window fontface "SimSun"`', '把图形窗口字体设为宋体,以支持这里的中文标题与坐标轴文字;实际显示取决于本机字体。'],
['L113 · `twoway (...) (...)`', '把两个图层叠加在同一坐标系:scatter 散点层与 lfit 线性拟合层。'],
['`scatter consume income`', '先写纵轴变量消费,后写横轴变量收入;每条家庭记录对应一个点,完全相同的点会重合。'],
['`mcolor("26 71 111") msymbol(O) msize(small)`', '散点为指定 RGB 深蓝色、圆形符号、小尺寸。'],
['`lfit consume income`', '使用当前数据拟合带截距的一元 OLS 直线并绘制,不是把相邻散点连起来,也不直接读取 fitted 列。当前数据和设定相同,因此与前面的 OLS 拟合线一致。'],
['`lcolor("174 44 51") lwidth(medthin)`', '拟合线为指定 RGB 红色、中偏细线宽。'],
['`title(...) xtitle(...) ytitle(...)`', '分别设置主标题、横轴标题、纵轴标题及文字大小;navy 指定标题颜色。'],
['`xlabel(1000(500)3500)`', '横轴刻度从 1000 到 3500,间隔 500;这是刻度控制,不是筛选收入。'],
['`ylabel(500(500)3000, angle(90) grid glcolor(...))`', '纵轴刻度从 500 到 3000、间隔 500;标签旋转 90 度,添加指定浅色网格。'],
['`xscale(range(500 4000)) yscale(range(0 3600))`', '设置坐标轴范围,留出边距;不删除范围之外的记录,也不改变回归样本。'],
['`legend(order(1 ... 2 ...) rows(1) region(...))`', '图例按散点、拟合线的顺序排列成一行,并设黑色边框、白色背景。'],
['`graphregion(...) plotregion(...)`', '整张图背景设为浅蓝色,内部绘图区设为白色。'],
['`xsize(9) ysize(6.55)`', '指定图形物理宽高(英寸),决定总体比例;PNG 像素宽度在导出时另设。'],
['`name(consumption_fit, replace)`', '给内存图形命名,并允许替换同名内存图;与磁盘文件覆盖是不同层次。'],
['L114 · `graph save ...gph`', '保存 Stata 原生图形,可在 Stata 中重新打开。'],
['L115 · `graph export ...png, width(1800)`', '导出宽 1800 像素的位图。'],
['L116–117 · SVG、PDF 导出', '导出可缩放矢量图与适合打印的 PDF;这些 replace 允许覆盖对应磁盘文件。'],
])
sections[-1]['blocks'].append(('figure', 'consumption_fit.png', '现有 Stata 输出:家庭消费散点与 OLS 拟合线(2026-10-23)'))
paragraph('这幅图没有置信带,也没有预测区间。固定收入处的竖直点列是该收入组的经验消费分布,不意味着已知真实总体分布。原文件 L113 是一整条长命令,本页面仅自动折行显示,没有改变原始语句。[S11]')
section('robust', '11 · 计算异方差稳健标准误并导出', 119, 136)
table(['行号 / 命令', '解释'], [
['L119 · `* ...`', '行首星号表示注释,说明下一段是稳健标准误的敏感性检查,不执行运算。'],
['L120 · `regress ... , vce(robust)`', '同一模型仍以 OLS 求系数,用异方差稳健协方差估计计算标准误;本例是非聚类 HC1,带 N/(N−k)=98/96 修正。'],
['L121–122 · `estimates store/save`', '分别在内存和磁盘保存稳健版本,名字为 Robust、文件为 consumption_robust.ster。'],
['L123–130 · 生成稳健系数表', '复用 L71–78 的流程,但此时 _se[] 来自稳健协方差矩阵;脚本仍用 e(df_r)=96 的 t 分布计算 p 值和区间。'],
['L131–136 · 导出并恢复数据', '将 robust_coefficients.dta 转成 CSV,最后恢复逐户明细。'],
])
formula('HC1 协方差 = [N/(N−k)] × (X′X)⁻¹ [Σ xᵢxᵢ′êᵢ²] (X′X)⁻¹;这里 k=2,X 含截距列。')
table(['变量', '系数(两次相同)', '常规 SE', 'HC1 SE', '稳健 p 值', '稳健 95% 区间'], [
[term, f"{robust[term]['estimate']:.6f}", f"{coefs[term]['se']:.6f}", f"{robust[term]['se']:.6f}", f"{robust[term]['p']:.6g}", f"[{robust[term]['ci_low']:.6f}, {robust[term]['ci_high']:.6f}]"]
for term in ('income', '_cons')
])
note('稳健标准误不一定更大', '本例收入系数的标准误从约 0.020302 增到 0.021428,而截距的标准误从约 47.096629 降到 43.447563。稳健处理针对方差估计,不会消除异方差,也不会解决遗漏变量或内生性。')
paragraph('这里不是抗异常值的稳健回归算法(例如 rreg),也没有使用聚类标准误。稳健 t 推断仍依赖相应独立性、外生性和大样本近似条件。HC1 修正及当前 regress 行为依据 Stata 15 自带官方帮助 [S7]。')
section('hettest', '12 · 恢复 OLS 并进行异方差检验', 137, 140)
table(['行号 / 命令', '解释'], [
['L137 · `estimates restore OLS`', '恢复 L56 保存的常规 OLS 估计结果。L136 的 restore 只恢复了数据,没有把当前稳健估计切回常规 OLS。'],
['L138 · `estat hettest`', '执行 Breusch–Pagan / Cook–Weisberg 异方差检验。无变量列表时,默认以拟合值为方差解释变量;默认 normal 版本有独立正态扰动假定。'],
['L139–140 · 保存 `r(chi2)` 和 `r(p)`', '把检验统计量及 p 值保存为标量,供最终模型汇总表使用。这里是 r() 而不是 e()。'],
])
paragraph(f"现有结果:χ²(1) = {model['bp_chi2']:.6f},p = {model['bp_p']:.9f}。在默认检验条件下,5% 水平拒绝同方差原假设,提示方差可能随拟合水平变化;不能据此断定是哪条记录有问题。")
note('这一行 restore 有实际作用', '本机 Stata 15 的 hettest.ado 会拒绝直接接在 vce(robust) 的结果后运行。因此 estimates restore OLS 是必要的模型切换;即使两次系数相同,协方差类型也不同。')
paragraph('不要把这里默认 normal 版本的统计量一概写成辅助回归的 N×R²;Stata 的 iid 选项才使用相应的 N×R² 版本。本文件没有添加 iid、fstat 或 rhs。命令行为已核对本机 regress_postestimation.sthlp 及 hettest.ado。[S7]')
section('model-export', '13 · 将模型汇总指标写成一行 CSV', 142, 161)
table(['行号 / 命令', '解释'], [
['L142–144 · `preserve`、`clear`、`set obs 1`', '先保存当前家庭数据,再清空数据并创建 1 条空观测。这里是 clear,不是开头的 clear all;前面保存的标量仍可使用。'],
['L145–148 · `generate N/intercept/slope/r_squared`', '把样本量、截距、斜率、R² 标量复制为单行数据表的列。'],
['L149–153 · `root_mse/rss/F/df_resid/p_F`', '复制误差尺度、残差平方和、常规 F、残差自由度及常规 F 检验 p 值。F 取自最初 OLS 标量,不是稳健回归的 F。'],
['L154–157 · 残差和与 BP 结果', '复制两项正规方程核对值及异方差检验统计量和 p 值。'],
['L158–160 · 格式与导出', '再次选择数值变量并设格式,将 13 列、1 行的结果导出为 model_statistics.csv。原代码没有为这个汇总表另存 DTA。'],
['L161 · `restore`', '恢复原来的 98 条家庭明细。'],
])
paragraph('generate 没有逐一写 double,是因为 L4 已设置默认类型;例如 generate N = scalar(N_ols) 得到一个数值变量。这里 N 只是列名,区别于 Stata 系统量 _N。该段的用意是把分散的标量整理成通用软件易于读取的一行表。[S1]、[S3]、[S6]')
section('finish', '14 · 完成标记、关闭日志与退出', 163, 165)
table(['行号 / 命令', '解释'], [
['L163 · `display "WEEK7_ANALYSIS_COMPLETED"`', '打印完成标记,用于定位是否执行到末尾。检查日志时应查找实际输出的标记行,而不只搜索被回显的 display 命令。'],
['L164 · `log close`', '关闭显式打开的分析日志,让日志完整写入磁盘。'],
['L165 · `exit, clear`', '在 do 文件中结束当前执行并返回调用者;clear 允许在退出 Stata 的情境下放弃未保存的数据,不是保存命令。通过 /e 启动时,批处理入口会在 do 执行结束后退出应用;若要从 do 内明确强制关闭 Stata,使用 exit, STATA clear。'],
])
paragraph('既有 week7_stata.log 中有独立的完成标记输出行和关闭日志记录,没有 r(数字); 错误结束行。为核对退出语境,另在给定 Stata 可执行文件中运行了不读取原数据的最小测试:子 do 执行 exit, clear 后,父 do 的下一条 display 仍能执行;这个测试没有重跑原分析脚本。有关调用情境参见本机官方 [P] exit 与 [R] exit 帮助。[S12]')
section('files', '文件索引:每个输出保存了什么')
table(['文件', '内容 / 规模', '产生位置'], [
['`source_totals_audit.dta` / `.csv`', '10 个收入组的明细计数与合计差额', 'L15–34'],
['`households_clean.dta` / `.csv`', '98 条家庭记录,4 个基础变量', 'L49–52'],
['`consumption_ols.ster`', '常规 OLS 估计结果', 'L57'],
['`regression_coefficients.dta` / `.csv`', '收入与截距两行常规系数表', 'L72–84'],
['`households_fitted.dta` / `.csv`', '98 条记录;4 个基础变量 + fitted、residual、residual_sq', 'L94–97'],
['`income_group_summary.dta` / `.csv`', '10 组;收入、人数、均值、标准差、最小值、最大值、拟合均值、平均残差', 'L104–107'],
['`consumption_fit.gph` / `.png` / `.svg` / `.pdf`', '同一散点与拟合线图的四种格式', 'L114–117'],
['`consumption_robust.ster`', '稳健协方差版本的估计结果', 'L122'],
['`robust_coefficients.dta` / `.csv`', '两行 HC1 系数推断表', 'L124–136'],
['`model_statistics.csv`', '1 行 13 列的模型汇总,未另存 DTA', 'L160'],
['`week7_stata.log`', '脚本主动打开的纯文本日志', 'L8、L164'],
['`week7_analysis.log`', '既有 /e 批处理执行产生的外层日志,不由脚本 log using 命名', '运行方式产生'],
])
paragraph('本次新建的 Week7_Stata代码详解.md 和 Week7_Stata代码详解.html 是代码解释文档;原 do 文件不会自动生成它们。原有“Week7_回归分析报告”是结果报告,与本次逐段代码说明分别保留。')
section('checkpoints', '理解与复现时最容易出错的地方')
table(['容易误读的地方', '正确读法'], [
['把第 17 行合计当作额外家庭', '合计只做审计;keep in 2/14 后它已被移除。'],
['认为检出合计差额就已更正数据', '程序只记录差异,回归始终使用可见明细。'],
['认为所有 assert 都是统计假设检验', '这里的 assert 是代码条件检查,失败时终止;它们没有检验显著性水平。'],
['将空白当 0 或自动删掉重复金额', '只删除 missing(consume);0 值和相同金额并不会因此被删掉。'],
['把 excel_row 直接理解成家庭编号', '它追踪原表行,长表中会跨收入重复;household_id 也是新建顺序号。'],
['以为 format 可提高存储精度', 'double/recast 管存储,format 管显示,datafmt 决定 CSV 是否沿用该显示格式。'],
['在系数导出循环中插入另一次 regress', '会替换 e()、_b[]、_se[],使表格混入另一个模型结果。'],
['将残差零和当作外生性证据', '零和是含截距 OLS 的代数性质,不检验 E(u|X)=0。'],
['将图中的线当作各组均值连线', 'lfit 是全样本一元 OLS 直线,组均值不必在线上。'],
['以为 vce(robust) 消除了异方差', '它调整推断所用协方差;当前系数、拟合值、残差不变。'],
['把默认 hettest 当作无任何分布假定的检验', '这里默认 normal 版本有相应假定,脚本没有请求 iid 版本。'],
['把高 R² 解读成收入的因果作用', '这是本组明细的单变量线性拟合,需要额外识别假设才能作因果解释。'],
])
section('run', '如何在给定路径下运行和查阅')
paragraph('完整复现时,在 Stata 命令窗口运行下面的 do 命令。现有脚本包含 clear all、多处 replace 和末尾 exit;它面向完整批处理流程,重跑会覆盖同名分析输出。本文只说明原代码的这些实际行为。')
code('do "C:/Users/cattom/Documents/econometrics/Week7/week7_analysis.do"')
paragraph('也可在 Windows PowerShell 启动独立 Stata 进程并等待完成。下面的可执行文件路径以 .exe 结束,不带末尾目录分隔符。此命令是供复现的示例,本次未执行。')
code('Start-Process -FilePath "D:\\Stata15\\StataSE-64.exe" `\n -ArgumentList \'/e do "C:\\Users\\cattom\\Documents\\econometrics\\Week7\\week7_analysis.do"\' `\n -WorkingDirectory "C:\\Users\\cattom\\Documents\\econometrics\\Week7" `\n -WindowStyle Hidden -Wait', 'powershell')
paragraph('读已有结果时,CSV 可以直接打开;DTA 用 use 读取,STER 用 estimates use 读取。例如:')
code('use "C:/Users/cattom/Documents/econometrics/Week7/households_fitted.dta", clear\nestimates use "C:/Users/cattom/Documents/econometrics/Week7/consumption_ols.ster"\nestimates replay')
paragraph('若要交互式逐段学习,可在另存的练习副本中分段运行,并把宏定义、循环以及对应的 preserve/restore 放在同一执行块内。不要直接把带 L 行号的展示文本当作可执行命令;网页代码块的“复制代码”按钮复制的是无行号的原代码。')
section('sources', '核对依据与文档范围')
paragraph('以下来源均是本机可直接核查的文件,命令说明优先对应 Stata 15 安装附带的官方帮助。本文不依赖在线脚本、字体或公式服务。网页图形内嵌,可离线单独阅读;Markdown 使用同目录 consumption_fit.png 的相对图片引用。')
table(['编号', '本机官方文档(相对 D:/Stata15/ado/base/)', '支撑内容'], [
['[S1]', '`v/version.sthlp`、`c/clear.sthlp`、`g/generate.sthlp`、`m/macro.sthlp`', '版本控制、清空数据、新变量类型、局部宏等。'],
['[S2]', '`p/post.sthlp`', 'postfile/post/postclose、文件句柄、逐行写入表。'],
['[S3]', '`f/format.sthlp`、`i/import_delimited.sthlp`', '显示格式、datafmt 与 CSV 导出。'],
['[S4]', '`i/import_excel.sthlp`、`r/reshape.sthlp`', 'Excel 导入与宽长表转换。'],
['[S5]', '`s/summarize.sthlp`', 'summarize、meanonly、r(N)/r(sum) 等返回值。'],
['[S6]', '`p/preserve.sthlp`', '保存并恢复当前数据集。'],
['[S7]', '`r/regress.sthlp`、`r/regress_postestimation.sthlp`、`h/hettest.ado`', 'OLS、HC1、predict、e()、BP/CW 默认设定及 robust 后的限制。'],
['[S8]', '`e/estimates_store.sthlp`、`e/estimates_save.sthlp`', '内存与磁盘估计结果存储。'],
['[S9]', '`f/f_ttail.ihlp`、`f/f_invttail.ihlp`、`f/f_ftail.ihlp`', 'ttail、invttail、Ftail 的官方函数说明(由同名 .sthlp 引用)。'],
['[S10]', '`c/collapse.sthlp`', '组内 count/mean/sd/min/max 汇总。'],
['[S11]', '`t/twoway_lfit.sthlp`、`s/scatter.sthlp` 及相应 graph 帮助', '散点、线性拟合层及图形选项。'],
['[S12]', '`e/exit.sthlp`、`e/exit_program.sthlp`', '退出应用与退出程序/do 文件的语境。'],
])
paragraph('数值依据:原始 Excel、week7_stata.log、households_clean.csv、households_fitted.csv、source_totals_audit.csv、regression_coefficients.csv、robust_coefficients.csv、income_group_summary.csv、model_statistics.csv。来源单元格、计数、系数、两种标准误、p 值与区间、拟合值、残差、R²、Root MSE、F、分组统计及默认 BP/CW 检验均已独立核对。')
paragraph('代码来源 SHA-256:`' + hashlib.sha256(source_bytes).hexdigest() + '`。行号与本文解释对应 2026 年 10 月 26 日读取的这份 165 行脚本;以后增删代码会改变行号。')
assert {i for i, line in enumerate(lines, start=1) if line.strip()} <= covered
def inline(text):
# Preserve Stata's unmatched macro backtick inside Markdown double-backtick spans.
tokens = re.split(r'(``[^\n]*?``|`[^`\n]+`|\*\*[^\n]+?\*\*)', str(text))
parts = []
for token in tokens:
if token.startswith('``') and token.endswith('``'):
parts.append('<code>' + html.escape(token[2:-2].strip()) + '</code>')
elif token.startswith('`') and token.endswith('`'):
parts.append('<code>' + html.escape(token[1:-1]) + '</code>')
elif token.startswith('**') and token.endswith('**'):
parts.append('<strong>' + html.escape(token[2:-2]) + '</strong>')
else:
parts.append(html.escape(token))
return ''.join(parts)
md = ['# Week 7 · Stata 代码逐段详解',
f'编写与核对:{DATE} · 对象:`week7_analysis.do`(165 行) · 工作目录:`C:/Users/cattom/Documents/econometrics/Week7/`',
'## 目录',
'\n'.join(f"- [{s['title']}](#{s['id']})" for s in sections)]
body = []
for item in sections:
md.extend([f'<a id="{item["id"]}"></a>', '## ' + item['title']])
line_badge = ''
if item['range']:
start, end = item['range']
md.append(f'**原文件第 {start}–{end} 行**')
line_badge = f'<span class="line-badge">L{start}–{end}</span>'
body.append(f'<section id="{item["id"]}" aria-labelledby="heading-{item["id"]}"><div class="section-heading"><h2 id="heading-{item["id"]}">{html.escape(item["title"])}</h2>{line_badge}</div>')
for block in item['blocks']:
kind = block[0]
if kind == 'p':
md.append(block[1])
body.append('<p>' + inline(block[1]) + '</p>')
elif kind == 'formula':
md.append('> ' + block[1])
body.append('<div class="formula">' + html.escape(block[1]) + '</div>')
elif kind == 'note':
md.append('> **' + block[1] + '**\n>\n> ' + block[2])
body.append('<aside class="note"><strong>' + html.escape(block[1]) + '</strong><p>' + inline(block[2]) + '</p></aside>')
elif kind == 'table':
headers, rows = block[1:]
md.append('| ' + ' | '.join(headers) + ' |\n| ' + ' | '.join(['---'] * len(headers)) + ' |\n' +
'\n'.join('| ' + ' | '.join(str(c).replace('|', '\\|') for c in row) + ' |' for row in rows))
body.append('<div class="table-wrap" tabindex="0" role="region" aria-label="' + html.escape(item['title'], quote=True) + ':解释表"><table><thead><tr>' + ''.join('<th scope="col">' + inline(h) + '</th>' for h in headers) + '</tr></thead><tbody>' +
''.join('<tr>' + ''.join('<td>' + inline(c) + '</td>' for c in row) + '</tr>' for row in rows) + '</tbody></table></div>')
elif kind == 'code':
text, start, language = block[1:]
md.append('```' + language + '\n' + text + '\n```')
encoded = base64.b64encode(text.encode('utf-8')).decode('ascii')
code_lines = text.splitlines()
rows = ''.join('<span class="code-line"><span class="ln" aria-hidden="true">' +
(str(start + i) if start else '') + '</span><span class="src">' + html.escape(line) + '</span></span>' for i, line in enumerate(code_lines))
body.append(f'<div class="code-block" data-source="{encoded}"><div class="code-toolbar"><span>{language.upper()}' + (f' · 原文件 L{start}–{start + len(code_lines) - 1}' if start else ' · 操作示例') + '</span><button type="button" class="copy-btn">复制代码</button></div><pre><code>' + rows + '</code></pre></div>')
elif kind == 'figure':
filename, alt = block[1:]
md.append(f'![{alt}]({filename})')
img64 = base64.b64encode((ROOT / filename).read_bytes()).decode('ascii')
body.append(f'<figure><img src="data:image/png;base64,{img64}" alt="{html.escape(alt, quote=True)}" width="1800" height="1310"><figcaption>{html.escape(alt)}</figcaption></figure>')
body.append('</section>')
STYLE = r'''
:root{--ink:#1d3042;--muted:#536675;--accent:#116b68;--accent-light:#e9f5f1;--border:#dce5e9;--paper:#fff;--bg:#f4f6f8;--code:#162a3a;--serif:Georgia,"Noto Serif SC","Source Han Serif SC","Songti SC",SimSun,serif;--sans:"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif;--mono:Consolas,"Cascadia Code","Microsoft YaHei",monospace}
*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:28px}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.85 var(--sans)}a{color:var(--accent);text-decoration-thickness:1px;text-underline-offset:4px}a:focus-visible,button:focus-visible,summary:focus-visible,.table-wrap:focus-visible{outline:3px solid #dc9a25;outline-offset:4px}.skip-link{position:fixed;top:-70px;left:16px;z-index:9;padding:8px 15px;background:white}.skip-link:focus{top:8px}.reading-progress{position:fixed;top:0;left:0;height:4px;width:0;background:#d6a34c;z-index:10}
header{background:#163549;color:#fff;border-bottom:6px solid #d7aa5c}.hero{max-width:1390px;margin:auto;padding:54px 48px 38px}.eyebrow{font:600 12px/1.5 var(--sans);letter-spacing:2.5px;color:#b9d4d9}.hero h1{font:600 clamp(32px,4vw,50px)/1.32 var(--serif);letter-spacing:.02em;margin:16px 0 14px}.hero h1 span{color:#dcecea}.dek{max-width:850px;color:#d6e3e9;font-size:17px;margin:0 0 23px}.meta{display:flex;gap:9px;flex-wrap:wrap}.meta span{border:1px solid #4b697a;border-radius:4px;padding:4px 11px;font-size:12px;color:#e3edf2}.hero-bottom{display:flex;justify-content:space-between;align-items:center;gap:22px;margin-top:27px;padding-top:22px;border-top:1px solid #446274}.flow{display:flex;flex-wrap:wrap;align-items:center;gap:9px;color:#dce9e8;font-size:13px}.flow b{color:#d7aa5c;font-weight:400}.print-btn{background:transparent;border:1px solid #70919e;color:#fff;border-radius:5px;padding:8px 16px;white-space:nowrap;cursor:pointer;font:inherit;font-size:13px}
.layout{max-width:1390px;margin:auto;display:grid;grid-template-columns:252px minmax(0,1fr);gap:26px;padding:30px 34px 70px}.sidebar{position:sticky;top:24px;align-self:start;max-height:calc(100vh - 48px);overflow:auto;padding:7px 10px 18px 0;scrollbar-width:thin}.sidebar .nav-label{font-size:12px;letter-spacing:2px;color:var(--muted);margin:0 0 12px 10px}.sidebar a{display:block;font-size:13px;line-height:1.5;padding:7px 10px;margin:2px 0;color:#4a5e6d;text-decoration:none;border-left:3px solid transparent;border-radius:0 4px 4px 0}.sidebar a:hover,.sidebar a[aria-current=true]{background:#e3eeed;border-left-color:var(--accent);color:#114d4c}.sidebar .nav-foot{font-size:11px;line-height:1.8;margin:18px 10px;color:#5c6b76}.mobile-nav{display:none}main{min-width:0;background:var(--paper);border:1px solid var(--border);border-radius:9px;padding:35px 42px;box-shadow:0 8px 28px #16354905}.section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin:0 0 20px}.section-heading h2{font:600 24px/1.48 var(--serif);margin:0;color:#16394e;overflow-wrap:anywhere}.line-badge{font:12px/1.5 var(--mono);padding:4px 8px;flex-shrink:0;background:#edf3f6;border:1px solid #d8e2e9;border-radius:4px;color:#435b6b;margin-top:3px}section+section{border-top:1px solid var(--border);padding-top:34px;margin-top:38px}p{margin:14px 0;overflow-wrap:anywhere}strong{font-weight:650;color:#16394e}code{font: .89em/1.6 var(--mono);background:#edf2f5;color:#213e54;border-radius:3px;padding:.1em .3em;overflow-wrap:anywhere}.note{border-left:4px solid #c49b48;padding:14px 19px;margin:22px 0;background:#fcf8ef;border-radius:0 5px 5px 0}.note>strong{font-size:15px;color:#72511c}.note p{font-size:14px;line-height:1.9;margin:5px 0 0}.formula{border-left:4px solid var(--accent);padding:18px 20px;margin:20px 0;background:var(--accent-light);font:500 17px/1.8 var(--serif);color:#154d4b;overflow-wrap:anywhere}
.table-wrap{overflow-x:auto;margin:20px 0;border:1px solid var(--border);border-radius:5px;scrollbar-width:thin}table{border-collapse:collapse;width:100%;font-size:14px;line-height:1.8}th{background:#eff4f6;color:#21485b;text-align:left;font-weight:650;padding:12px 14px;white-space:nowrap}td{padding:12px 14px;vertical-align:top;border-top:1px solid #e4eaee;min-width:100px;overflow-wrap:anywhere}td:first-child{color:#234456;font-weight:500;min-width:155px}tr:nth-child(even) td{background:#fbfcfd}td code{font-size:12.5px;line-height:1.75}th:first-child{width:29%}.code-block{margin:20px 0 24px;border:1px solid #284255;border-radius:6px;overflow:hidden;background:var(--code);color:#e1eaf0}.code-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 14px;background:#233d50;border-bottom:1px solid #365265;gap:12px;color:#bed0dc;font:11px/1.5 var(--mono);letter-spacing:.3px}.copy-btn{font:12px/1.4 var(--sans);border:1px solid #688493;border-radius:3px;padding:5px 9px;color:#edf5f8;background:transparent;cursor:pointer;flex-shrink:0}.copy-btn:hover{background:#3b596c}pre{margin:0;padding:15px 0;overflow:visible}pre code{background:none;padding:0;color:inherit;border-radius:0;font:12.8px/1.85 var(--mono);white-space:normal}.code-line{display:grid;grid-template-columns:44px minmax(0,1fr);padding:0 15px 0 0;min-height:1.85em}.ln{user-select:none;text-align:right;padding-right:12px;color:#8aa3b5;white-space:nowrap}.src{white-space:pre-wrap;overflow-wrap:anywhere;tab-size:4}.code-line:hover{background:#233e50}figure{margin:25px 0 20px}figure img{display:block;width:100%;height:auto;border:1px solid #d3dfe6;border-radius:6px}figcaption{font-size:12px;text-align:center;color:var(--muted);padding:9px 10px}footer{max-width:1250px;margin:0 auto 28px;padding:0 30px;font-size:12px;color:#5d6e79;text-align:center}.back-top{position:fixed;right:18px;bottom:18px;background:#174f60;color:white;border:0;border-radius:5px;padding:10px 14px;cursor:pointer;box-shadow:0 3px 12px #16354922;font:13px var(--sans)}.live-status{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#193f50;color:white;padding:8px 15px;border-radius:5px;font-size:13px;z-index:12}.live-status:empty{display:none}
@media(min-width:1500px){.layout{grid-template-columns:270px minmax(0,1fr)}}
@media(max-width:1050px){.layout{grid-template-columns:205px minmax(0,1fr);gap:18px;padding:24px 22px 50px}.hero{padding:40px 32px 30px}main{padding:28px}.sidebar a{font-size:12px}.section-heading h2{font-size:22px}td,th{padding:10px 12px}}
@media(max-width:780px){.sidebar{display:none}.layout{display:block;padding:18px 14px 45px}.mobile-nav{display:block;max-width:100%;background:white;border-bottom:1px solid var(--border);padding:12px 22px}.mobile-nav summary{color:var(--accent);font-weight:600;cursor:pointer}.mobile-nav nav{padding-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:7px}.mobile-nav a{font-size:12px;text-decoration:none;padding:4px}main{padding:26px 24px}.hero{padding:34px 25px 26px}.hero-bottom{align-items:flex-start}.hero h1{font-size:35px}.dek{font-size:15px}.flow{font-size:12px}.section-heading{gap:10px}.section-heading h2{font-size:22px}section+section{padding-top:26px;margin-top:30px}}
@media(max-width:480px){body{font-size:15px}.hero{padding:28px 20px 22px}.hero h1{font-size:30px}.eyebrow{font-size:10px;letter-spacing:1.8px}.hero-bottom{display:block;margin-top:21px;padding-top:17px}.print-btn{margin-top:15px}.meta{gap:6px}.meta span{font-size:11px;padding:3px 7px}.layout{padding:12px 9px 40px}main{padding:22px 16px;border-radius:6px}.section-heading{display:block}.line-badge{display:inline-block;margin-top:9px}.section-heading h2{font-size:21px}.formula{font-size:16px;padding:14px}.note{padding:12px 14px}.note p{font-size:13px}.code-toolbar{padding:8px 9px;font-size:10px;letter-spacing:0}.code-line{grid-template-columns:31px minmax(0,1fr);padding-right:9px}.ln{padding-right:7px}.src{font-size:11.5px}table{font-size:13px}td:first-child{min-width:120px}td{min-width:150px}th{white-space:normal}.mobile-nav nav{grid-template-columns:1fr}.back-top{font-size:12px;padding:9px 11px;right:10px;bottom:10px}}
@media print{html{scroll-behavior:auto}body{background:white;font:10pt/1.6 var(--sans);color:#111}header{background:white;color:#111;border-bottom:2px solid #666}.hero{padding:0 0 18px;max-width:none}.hero h1{font-size:26pt}.hero h1 span,.dek,.eyebrow,.meta span,.flow{color:#222}.meta span{border-color:#aaa}.hero-bottom{border-color:#ccc}.sidebar,.mobile-nav,.print-btn,.copy-btn,.back-top,.reading-progress,.live-status,.skip-link{display:none!important}.layout{display:block;max-width:none;padding:20px 0 0}main{border:0;box-shadow:none;padding:0}.section-heading h2{font-size:16pt}section+section{margin-top:22px;padding-top:20px}.section-heading{break-after:avoid}.line-badge{font-size:9pt}table{font-size:8.5pt}tr{break-inside:avoid}.table-wrap{overflow:visible}td,th{padding:6px 8px;min-width:0!important}th{white-space:normal}.code-block{background:#f5f5f5;border:1px solid #ccc;color:#111;overflow:visible}.code-toolbar{background:#eee;color:#333;border-color:#ccc}.ln{color:#666}pre code,.src{font-size:8pt}.code-line{grid-template-columns:30px minmax(0,1fr)}.formula,.note{break-inside:avoid}.formula{font-size:11pt}figure{break-inside:avoid}footer{font-size:8pt;margin-top:20px}a{color:inherit;text-decoration:none}}
.hero .dek{max-width:1040px}
@media(max-width:480px){.hero h1 span{display:block}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
'''
nav_links = ''.join(f'<a href="#{item["id"]}">{html.escape(item["title"])}</a>' for item in sections)
SCRIPT = r'''
const statusEl = document.getElementById('copy-status');
let statusTimer;
function tell(message){statusEl.textContent=message;clearTimeout(statusTimer);statusTimer=setTimeout(()=>{statusEl.textContent='';},2300)}
document.querySelectorAll('.copy-btn').forEach(button=>button.addEventListener('click',async()=>{
const encoded=button.closest('.code-block').dataset.source;
const text=new TextDecoder().decode(Uint8Array.from(atob(encoded),c=>c.charCodeAt(0)));
try{
if(navigator.clipboard && window.isSecureContext){await navigator.clipboard.writeText(text)}
else{const area=document.createElement('textarea');area.value=text;area.style.position='fixed';area.style.opacity='0';document.body.append(area);area.select();const ok=document.execCommand('copy');area.remove();button.focus();if(!ok)throw new Error('copy failed')}
tell('已复制代码,不含行号');
}catch(error){tell('浏览器未允许复制,请选中代码手动复制')}
}));
document.getElementById('print-btn').addEventListener('click',()=>window.print());
document.getElementById('back-top').addEventListener('click',()=>window.scrollTo({top:0,behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'}));
const sectionNodes=[...document.querySelectorAll('main section')];
const navNodes=[...document.querySelectorAll('.sidebar a')];
let scrollPending=false;
function updateReading(){
const span=document.documentElement.scrollHeight-innerHeight;
document.getElementById('reading-progress').style.width=(span>0?Math.min(100,Math.max(0,scrollY/span*100)):0)+'%';
let current=sectionNodes[0].id;
for(const section of sectionNodes){if(section.getBoundingClientRect().top<=120)current=section.id;else break}
navNodes.forEach(link=>{if(link.getAttribute('href')==='#'+current)link.setAttribute('aria-current','true');else link.removeAttribute('aria-current')});
scrollPending=false;
}
addEventListener('scroll',()=>{if(!scrollPending){scrollPending=true;requestAnimationFrame(updateReading)}},{passive:true});
addEventListener('resize',updateReading);
document.querySelectorAll('.mobile-nav a').forEach(link=>link.addEventListener('click',()=>{link.closest('details').open=false}));
updateReading();
'''
page = '<!DOCTYPE html>\n<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' + \
'<meta name="description" content="week7_analysis.do 的中文逐段代码详解,涵盖全部 165 行:Excel 整理、OLS 回归、残差、分组、绘图与稳健推断。离线 HTML5。">' + \
'<title>Week 7 · Stata 代码逐段详解</title><style>' + STYLE + '</style></head><body>' + \
'<a class="skip-link" href="#main">跳转到正文</a><div class="reading-progress" id="reading-progress" aria-hidden="true"></div>' + \
'<header id="top"><div class="hero"><div class="eyebrow">ECONOMETRICS / WEEK 07 / CODE READING</div>' + \
'<h1>读懂每一步,<span>从数据到回归</span></h1><p class="dek">week7_analysis.do 中文逐段详解。对照原文件行号,理解命令如何改变数据、保存结果,以及统计量的实际含义。</p>' + \
'<div class="meta"><span>165 行源代码</span><span>14 个执行步骤</span><span>98 条家庭记录</span><span>Stata 15 语法</span><span>2026-10-26 核对</span></div>' + \
'<div class="hero-bottom"><div class="flow" aria-label="分析流程"><span>Excel 与合计核对</span><b>→</b><span>宽表转长表</span><b>→</b><span>OLS 与残差</span><b>→</b><span>图形与稳健推断</span></div><button id="print-btn" class="print-btn" type="button">打印 / 保存 PDF</button></div></div></header>' + \
'<details class="mobile-nav"><summary>展开章节目录</summary><nav aria-label="移动端章节目录">' + nav_links + '</nav></details>' + \
'<div class="layout"><aside class="sidebar"><p class="nav-label">阅读目录</p><nav aria-label="章节目录">' + nav_links + '</nav><p class="nav-foot">代码逐段引用原文件<br>图片、样式与脚本内嵌<br>无网络依赖 · 支持打印</p></aside><main id="main">' + ''.join(body) + '</main></div>' + \
'<footer>编写与核对 2026-10-26 · 结果日志 2026-10-23 · 原 do 文件与既有分析产物保持原样</footer>' + \
'<button type="button" id="back-top" class="back-top" aria-label="返回页面顶部">↑ 返回顶部</button><div id="copy-status" class="live-status" role="status" aria-live="polite"></div><script>' + SCRIPT + '</script></body></html>\n'
class ValidateHTML(HTMLParser):
def __init__(self):
super().__init__()
self.ids = []
self.targets = []
self.code_blocks = []
self.sections = 0
self.external = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if 'id' in attrs:
self.ids.append(attrs['id'])
if tag == 'a' and attrs.get('href', '').startswith('#'):
self.targets.append(attrs['href'][1:])
if tag == 'section':
self.sections += 1
if 'data-source' in attrs:
self.code_blocks.append(base64.b64decode(attrs['data-source']).decode('utf-8'))
if tag == 'img':
assert attrs.get('alt')
assert attrs['src'].startswith('data:image/png;base64,')
for key in ('src', 'href'):
if attrs.get(key, '').startswith(('https:', 'http:')):
self.external.append(attrs[key])
parser = ValidateHTML()
parser.feed(page)
assert len(parser.ids) == len(set(parser.ids))
assert set(parser.targets) <= set(parser.ids)
assert parser.sections == len(sections) == 20
assert not parser.external
assert '\ufffd' not in page
source_blocks = [item['blocks'][0][1] for item in sections if item['range']]
assert len(source_blocks) == 14
assert all(block in parser.code_blocks for block in source_blocks)
assert SOURCE.read_bytes() == source_bytes
MD_PATH.write_text('\n\n'.join(md) + '\n', encoding='utf-8')
HTML_PATH.write_text(page, encoding='utf-8')
verification = {
'status': 'passed', 'verification_date': DATE, 'existing_stata_run_date': '2026-10-23',
'original_do_rerun': False, 'source_do_unchanged': True,
'source_do_sha256': hashlib.sha256(source_bytes).hexdigest(),
'source_excel_sha256': hashlib.sha256((ROOT / 'Eg2_1_1/Eg2_1_1.xlsx').read_bytes()).hexdigest(),
'source_lines': len(lines), 'nonempty_source_lines': sum(bool(line.strip()) for line in lines),
'all_nonempty_source_lines_explained': True, 'verbatim_source_blocks': len(source_blocks),
'sections': len(sections), 'records': n, 'income_groups': len(groups),
'independent_ols': {'intercept': float(beta[0]), 'slope': float(beta[1])},
'independent_default_bp': float(bp),
'max_abs_fitted_difference': float(max(abs(np.array([row['fitted'] for row in fitted]) - pred))),
'checks': [
'98 Excel records, worksheet rows and generated IDs match clean and fitted CSV files',
'all 10 income-group counts, totals, reported totals and differences match Excel',
'OLS coefficients, conventional and HC1 standard errors, t statistics, p values and confidence intervals',
'fitted values, residuals, residual squares, RSS, R2, Root MSE and classical F test',
'all group means, sample standard deviations, min/max, fitted means and mean residuals',
'default normal BP/CW statistic and p value (not the iid N*R2 variant)',
'existing Stata log actual completion marker and absence of r() error lines',
'14 verbatim source excerpts cover every nonempty source line',
'HTML5, UTF-8, unique anchors, embedded image, no external resources',
],
'outputs': [str(MD_PATH), str(HTML_PATH)]
}
(ASSETS / 'verification.json').write_text(json.dumps(verification, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps({'status': 'passed', 'records': n, 'sections': len(sections),
'markdown': str(MD_PATH), 'html': str(HTML_PATH)}, ensure_ascii=False, indent=2))