663 lines
64 KiB
Python
663 lines
64 KiB
Python
"""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'')
|
||
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))
|