
1. 项目概述当“对照组”自己长出来时我们该怎么信它你有没有遇到过这种场景想评估一个地方新出台的环保政策到底有没有效果但全国就这一个城市试点其他城市要么经济结构不同要么人口规模差太多硬凑出来的“对照组”怎么看都像临时拼凑的——数据对不上、趋势不一致、说服力弱得连自己都说服不了。或者你想知道某家连锁超市在某个区域突然上线的会员积分活动到底带来了多少额外销售额可偏偏这个区域过去三年没搞过类似活动历史数据里找不到可比样本。这时候传统A/B测试的黄金法则直接失效随机分组成了奢望而简单的前后对比又会被季节性波动、宏观经济变化这些“噪音”彻底淹没。Synthetic Control Groups合成控制组就是为解决这类“单点干预、无天然对照”的现实困境而生的利器。它不强求找一个现成的、一模一样的城市或门店而是用一堆“不太像但各有特点”的备选城市/门店通过加权组合亲手“捏”出一个虚拟的、在干预发生前和目标对象高度同步的“影子对照组”。这个“影子”不是凭空想象它的权重完全由数据驱动目标只有一个让合成组在干预前的历史轨迹尽可能严丝合缝地贴合真实目标组的轨迹。我第一次在客户现场用Python跑出合成控制结果时看到那条几乎重叠的干预前拟合曲线以及干预后清晰拉开的差距那种“原来数据真的能自己说话”的震撼感至今记得。它不是万能的魔法但当你手头只有单一案例、却急需一份有统计底气的归因报告时它就是你最值得信赖的“数据建筑师”。这篇文章就是带你从零开始亲手搭建这座建筑理解每一块砖怎么选、每一根梁怎么搭、最后怎么判断这座楼是否真的稳当。不需要你有计量经济学博士背景但需要你愿意跟着代码把每一个权重、每一条曲线背后的逻辑掰开揉碎了看明白。2. 核心思路拆解为什么是“合成”而不是“挑选”或“回归”2.1 传统方法的“硬伤”与合成控制的破局点要真正吃透合成控制得先看清它想解决的“老问题”有多棘手。最常见的替代方案无非两种一是“挑选一个相似地区”二是“用多元线性回归预测”。“挑选相似地区”听起来很直观比如选一个GDP、人口、产业结构都接近的城市当对照。但问题在于“相似”是个模糊概念。你挑A市同事可能觉得B市更像你用GDP和人口两个指标领导可能坚持还要加上教育水平和交通便利度。这种主观挑选本质上把科学问题变成了审美问题。更致命的是它只关注几个宏观指标的静态匹配完全忽略了时间维度上的动态演化。两个城市2020年GDP都是1万亿但一个过去五年年均增长8%另一个是3%它们对同一项政策的反应潜力天差地别。合成控制则完全不同它不追求静态的“像”而追求动态的“同步”。它要求合成组在干预前的每一年、每一个关键变量上都和目标组保持高度一致。这种基于时间序列的严格拟合把主观判断压缩到了极致剩下的全是数据说了算。“多元线性回归”看似更“科学”用一堆协变量去预测目标组的反事实结果。但这里埋着一个深坑模型设定偏误Model Specification Bias。如果你漏掉了一个关键驱动因素比如你只用了GDP和人口却忘了当地居民的环保意识指数或者错误地假设了变量间的关系比如认为GDP影响是线性的而实际是指数型的那么回归预测出来的“如果没有干预会怎样”就可能离真实情况十万八千里。合成控制巧妙地绕开了这个陷阱。它不预设任何函数形式不猜测变量间如何相互作用。它只是说“给我一堆‘原材料’城市我来给你配一个最优比例的‘鸡尾酒’让这杯酒在干预前的味道和目标城市一模一样。”这个“配比”过程本质上是一个带约束的优化问题目标函数就是最小化干预前的拟合误差。它不解释“为什么”会这样只确保“结果”是这样。这种“黑箱但稳健”的特性恰恰是它在政策评估等高风险决策场景中备受推崇的核心原因。2.2 “合成”的数学内核一个带约束的加权平均抛开所有术语合成控制的数学本质其实就是一个非常朴素的加权平均。假设我们有一个目标地区记为j1它在干预前有T₀个时间点的历史数据比如2015-2019年共5年每个时间点上有K个关键变量比如GDP、失业率、人均消费。同时我们有一堆潜在的“原材料”地区j2,3,...,J它们也都有这T₀个时间点、K个变量的数据。我们的任务就是找到一组权重w₂, w₃, ..., wⱼ满足非负性wⱼ ≥ 0。权重不能是负数这很好理解你不能说“减去0.2个北京的GDP”来构造上海。和为一w₂ w₃ ... wⱼ 1。所有原材料的贡献加起来必须等于100%的“目标”。有了这组权重我们就能计算出合成控制组在任意时间点t的第k个变量的值Y^scₖ(t) w₂ * Y₂ₖ(t) w₃ * Y₃ₖ(t) ... wⱼ * Yⱼₖ(t)这个公式本身毫无神秘感。真正的挑战在于如何找到那组能让Y^scₖ(t)在所有t∈[1,T₀]和所有k∈[1,K]上都无限逼近真实目标组Y₁ₖ(t)的权重这就是优化算法登场的地方。主流做法是定义一个“距离”函数比如均方误差MSEMinimize: Σ(t1 to T₀) Σ(k1 to K) [Y₁ₖ(t) - Y^scₖ(t)]²然后在非负性和和为一的约束下求解这个最小化问题。这个过程就是SyntheticControl库背后调用的cvxpy或scipy.optimize在默默完成的工作。它不是在猜而是在海量的权重组合中用数学的尺子精确地量出哪一把“钥匙”能最完美地打开那把“锁”。2.3 方案选型为什么是Python而不是R或Stata选择Python作为实现工具并非出于语言偏好而是基于三个非常务实的考量。第一生态整合性。一个完整的合成控制分析绝不仅仅是跑一个fit()函数。它始于数据清洗Pandas、可视化诊断Matplotlib/Seaborn、模型结果解读NumPy/Pandas终于将结论嵌入业务报告Jupyter Notebook导出PDF/HTML或自动化流程Airflow调度。Python的整个数据科学生态像一套严丝合缝的乐高积木。你可以在同一个Notebook里左边写几行代码清洗掉异常值中间画一张图检查数据质量右边直接跑模型并生成一份带图表的结论摘要。而如果切换到R虽然Synth包功能强大但后续的自动化部署、与公司内部API对接、或是集成进一个大型机器学习流水线其工程化成本会陡然升高。Stata在学术圈地位崇高但其封闭的环境和相对滞后的图形能力在需要快速迭代、灵活展示的商业分析场景中显得有些力不从心。第二可解释性与教学友好度。Python的语法近乎伪代码。w model.fit(X_train, y_train)这样的表达对于刚接触因果推断的业务分析师或产品经理远比R中synth(data, trunit, trperiod, predictors)这样的函数调用更易理解其意图。更重要的是Python社区有大量优秀的、面向初学者的教程和可视化库。你可以轻松地用plotly做出交互式图表让老板拖动时间轴亲眼看到合成组是如何一步步“学会”模仿目标组的也可以用shap库解释出每个“原材料”地区对最终合成结果的贡献度把抽象的权重变成一张直观的贡献热力图。这种“所见即所得”的能力是推动因果推断从学术象牙塔走向业务一线的关键桥梁。第三工程化落地的平滑路径。当一个分析模型被验证有效后下一步往往是将其产品化。比如将合成控制逻辑封装成一个微服务供销售部门实时查询某次促销活动的效果或是将其嵌入BI看板让区域经理每天都能看到自己辖区政策效果的动态评估。Python在Web服务Flask/FastAPI、容器化Docker、云平台AWS Lambda, GCP Cloud Functions上的支持是目前所有语言中最成熟、文档最丰富的。这意味着你今天在Jupyter里写下的分析代码明天就能以极小的改造成本变成一个稳定运行的线上服务。这种从“分析”到“行动”的无缝衔接是任何单一分析语言都无法比拟的核心优势。3. 核心细节解析与实操要点数据、权重与诊断的三重门3.1 数据准备不是“有数据就行”而是“有对的数据”合成控制对数据质量的要求堪称苛刻。它不像普通回归那样可以靠大样本稀释噪声。它的成败几乎完全系于干预前那段“训练期”的数据质量。我曾在一个零售客户的项目中栽过跟头他们提供了2018-2022年的月度销售数据看起来很完整。但当我们深入检查时发现2020年3月到6月由于疫情封控所有门店的销售数据都归零了。这段人为的、系统性的缺失并非随机噪声而是整个时间序列的一个巨大“断层”。如果我们不加处理就直接用模型会疯狂地试图用其他城市的正常数据去“拟合”这个断层结果就是合成组在2020年Q2的表现会严重失真进而污染整个反事实预测。因此数据准备绝不是导入CSV就完事它包含三个不可跳过的环节。第一时间对齐与完整性校验。所有“原材料”地区和目标地区必须拥有完全相同的时间点。不能A市有2015-2020年数据B市只有2016-2020年。缺失的年份必须明确是“真实缺失”还是“数据未采集”。对于后者最稳妥的做法是剔除该地区而不是用插值法补全。因为插值法如线性插值会引入模型无法识别的、人为制造的平滑性破坏时间序列的真实波动特征。一个简单但有效的检查脚本如下import pandas as pd # 假设df是宽格式数据索引为年份列为各地区 years_available df.index print(f数据覆盖年份: {years_available.min()} - {years_available.max()}) print(f总年份数: {len(years_available)}) # 检查每个地区是否有全量数据 missing_counts df.isnull().sum() regions_with_missing missing_counts[missing_counts 0].index.tolist() print(f存在缺失值的地区: {regions_with_missing})如果发现缺失优先考虑剔除其次才考虑用领域知识进行谨慎的、有依据的填补例如用该地区前三年的平均增长率来估算。第二变量选择少而精而非多而全。初学者常犯的错误是把所有能拿到的变量都塞进去GDP、人口、失业率、房价、汽车保有量、甚至天气数据。变量越多模型越“丰满”听起来越科学。但现实是无关变量会成为噪音稀释真正驱动因素的信号。合成控制的有效性依赖于“原材料”地区与目标地区在共同驱动因素上的相似性。因此变量选择必须回归业务本质。问自己三个问题1这个变量在干预发生前是否与我们要评估的结果如销售额、污染指数有稳定的、已知的因果关系2这个变量是否在干预后理论上不会直接受到干预的影响例如评估一项教育改革就不能用“高考升学率”作为预测变量因为它本身就是改革的直接结果。3这个变量是否在所有“原材料”地区都有高质量、可比的数据答案为“否”的变量果断舍弃。在我的经验里一个成功的合成控制分析核心预测变量通常不超过5个。比如评估一个城市的共享单车政策核心变量可能是常住人口、地铁线路总长度、平均通勤距离、人均可支配收入、以及上一年度的自行车保有量。这五个变量共同刻画了该城市的“出行需求基础”和“基础设施承载力”它们构成了一个稳健的、可解释的预测框架。第三数据标准化让不同量纲的变量“站在同一起跑线”。GDP可能是万亿级失业率是百分比这会导致优化算法在计算误差时被数值大的变量如GDP主导。想象一下GDP误差100亿和失业率误差0.1%在原始数值上前者可能比后者大上百万倍。模型为了最小化总误差会本能地优先拟合GDP而完全忽略失业率的拟合精度。这显然违背了我们的初衷。因此必须对所有预测变量进行标准化。最常用且推荐的方法是Z-score标准化X_std (X - X_mean) / X_std这一步必须在“原材料”地区和目标地区构成的整个数据集上统一进行而不是分别对每个地区做。这样才能保证比较的基准是一致的。scikit-learn的StandardScaler可以完美胜任此任务但务必记住fit_transform()只对训练数据即所有地区的干预前数据使用一次之后对任何新数据都只能用transform()。3.2 权重解读数字背后的“故事”与“陷阱”当模型输出一组权重比如[0.45, 0.30, 0.15, 0.10]分别对应北京、上海、广州、深圳这串数字本身并不重要。重要的是它讲了一个关于“谁最像你”的故事。权重最高的北京0.45意味着在模型看来北京的经济发展轨迹、人口结构变迁、消费习惯演进等综合特征与我们的目标城市假设是杭州在干预前最为神似。它是这个“合成杭州”的主料。而权重为0的地区则被模型彻底“拒之门外”这并非数据错误而是模型的理性选择——它发现无论怎么调整这个地区的比例都无法帮助它更好地拟合杭州的历史反而会增加误差。然而权重解读中藏着一个巨大的认知陷阱权重高 ≠ 地理或文化上最接近。我们曾评估一个西南小城的旅游振兴计划模型给出的最高权重0.62给了东北的一个工业老城。团队第一反应是“这肯定错了”。但深入分析后发现这个工业老城在2010年后经历了剧烈的产业转型大量废弃厂房被改造成文创园区其游客接待量、民宿数量、文旅投资增速等关键指标在2015-2019年间与目标小城的轨迹呈现出惊人的同步性。而那些地理上更近、文化上更相似的周边城市其旅游发展却一直平缓缺乏这种爆发式的、与目标小城同频的跃迁。这个案例深刻地提醒我们合成控制寻找的是动态行为模式上的相似而非静态标签上的相似。它是一个“行为经济学家”而不是一个“地理学家”。因此在报告中呈现权重时切忌只放一张饼图。必须辅以诊断性图表。最核心的是两张图干预前拟合图Pre-treatment Fit Plot这是你的“信用证”。横轴是时间纵轴是核心结果变量如GDP。画三条线真实目标组粗实线、合成控制组粗虚线、以及所有“原材料”地区各自的原始轨迹细灰线。这张图的黄金标准是在干预点Vertical Line之前粗实线和粗虚线必须严丝合缝肉眼难辨。如果它们之间有明显gap哪怕只有1-2%这个合成控制结果也基本不可信必须回溯数据或变量选择。权重贡献热力图Weight Contribution Heatmap用seaborn.heatmap绘制。行是各个“原材料”地区列是各个预测变量。单元格颜色深浅代表该地区在该变量上的“影响力”。这能帮你回答“为什么是北京权重最高”——可能是因为在北京的GDP和人均消费这两个变量上它对拟合杭州的贡献最大。这种细粒度的解读是赢得业务方信任的关键。提示永远不要在报告中只说“合成控制组的权重是...”。一定要配上拟合图并指着图说“您看干预前这五年合成组和真实组的GDP曲线几乎完全重叠这证明了我们的反事实预测是可靠的。”3.3 稳健性检验没有检验的结论都是空中楼阁一个未经稳健性检验的合成控制结果就像一座没有地基的玻璃房再漂亮也经不起推敲。我们必须设计一系列“压力测试”来拷问这个结论的坚固程度。以下是我在项目中必做的三项核心检验。第一安慰剂检验Placebo Test—— “如果干预没发生我们会看到什么”这是最核心、最有力的检验。它的逻辑是如果我们的方法是可靠的那么当我们把“干预”这个标签错误地贴在任何一个没有真正受到干预的“原材料”地区身上时我们不应该观察到显著的、持续的效应。具体操作是将每一个“原材料”地区依次当作“伪目标地区”用剩下的所有其他地区包括原来的目标地区去为它构建一个合成控制组然后计算它在“伪干预期”的效应。重复这个过程J-1次J是总地区数我们就得到了J-1个“伪效应”序列。将这些伪效应序列的分布与我们真实目标地区观测到的效应进行对比。如果真实效应落在了伪效应分布的极端尾部比如比95%的伪效应都要大我们就有95%的把握说这个效应是真实的而非随机波动。SyntheticControl库内置了placebo_test()方法但关键在于理解其输出。它会返回一个p-value但更重要的是看那个placebo_plot——一张所有伪效应灰色细线和真实效应红色粗线的叠加图。如果红色粗线鹤立鸡群那结论就站得住脚。第二滚动窗口检验Rolling Window Test—— “效应是突然出现还是缓慢累积”干预的效果往往不是一夜之间发生的。一个健康的效应应该在干预后的一段时间内逐渐显现并趋于稳定。滚动窗口检验就是用来捕捉这个动态过程的。我们不是只看干预后第一年或第一季而是定义一个滚动窗口比如连续3年计算在这个窗口期内合成组与真实组的平均差异。然后将这个窗口沿着时间轴向后滑动逐年计算。最终得到一条“效应强度随时间演变”的曲线。如果这条曲线在干预点后立刻飙升然后长期维持高位这是一个好信号。但如果它在干预点前就出现了异常波动或者在干预后起伏不定、没有明确趋势那就说明我们的模型可能受到了其他混杂因素的干扰结论需要谨慎对待。第三变量敏感性检验Variable Sensitivity Test—— “结论会不会因为换一个变量就崩塌”这是对变量选择合理性的终极拷问。我们回到数据准备阶段尝试几种不同的变量组合组合A基础版GDP人口、组合B扩展版GDP人口失业率消费、组合C精简版仅GDP。为每一种组合都重新运行一次完整的合成控制流程得到各自的效应估计值和拟合图。如果所有组合都给出了方向一致、幅度相近的正向效应比如都在5%到7%之间那么我们的结论就具有很强的稳健性。反之如果换一个变量效应就从5%变成-3%那说明我们的结论极度脆弱根源很可能在于变量选择不当或者核心驱动因素尚未被识别出来。这个检验强迫我们直面数据本身的局限性而不是沉溺于单一模型的“完美”输出。4. 实操过程与核心环节实现从零开始亲手构建你的第一个合成控制组4.1 环境搭建与工具链选择在动手写代码之前明确工具链是高效工作的前提。这不是一个“装了Python就能跑”的玩具项目它需要一套经过实战检验的、版本兼容的工具组合。我的标准配置如下Python版本3.9.x或3.10.x。避免使用最新的3.11因为部分科学计算库如cvxpy的Windows二进制包可能尚未完全适配容易在安装时遇到编译错误。核心库pandas1.5.3数据处理的基石版本锁定是为了避免1.4.x中某些groupby行为的细微变更影响结果复现。numpy1.23.5数值计算的底层引擎。matplotlib3.7.1seaborn0.12.2绘图双雄seaborn的高级统计图对诊断至关重要。scikit-learn1.2.2用于数据标准化和基础的机器学习辅助。合成控制专用库syntheticcontrol0.1.1。这是目前Python生态中最成熟、文档最完善的专用库由学术界和工业界共同维护。它封装了复杂的优化过程API设计简洁且内置了placebo_test等关键功能。绝对不要尝试用statsmodels或scipy从头手写优化那会耗费数天时间调试约束条件且极易出错。安装命令极其简单pip install pandas numpy matplotlib seaborn scikit-learn syntheticcontrol安装完成后务必在Python中执行一次导入测试import pandas as pd import numpy as np from syntheticcontrol import SyntheticControl print(All libraries imported successfully!)如果看到成功提示说明环境已就绪。这一步看似简单却是后续所有工作的基石。我见过太多团队卡在环境配置上白白浪费半天时间只因一个库的版本冲突。4.2 数据准备与预处理一场与数据的深度对话让我们以一个虚构但高度真实的案例来贯穿整个实操评估“杭州市2021年全面推行的‘绿色出行补贴’政策”对全市年度机动车保有量增长率的影响。我们的目标是回答“如果没有这项补贴杭州的机动车保有量在2021-2023年会增长多少”第一步数据收集与结构化。我们需要两类数据目标地区杭州2015-2023年每年的机动车保有量核心结果变量、常住人口、地铁运营里程、人均GDP、公交客运总量。潜在原材料地区备选池同样时间段内宁波、温州、绍兴、嘉兴、湖州、金华这六个浙江省内城市以及南京、合肥、南昌这三个邻省省会的上述全部变量。所有数据整理成一个宽格式的CSV文件data_raw.csv其结构如下year杭州_保有量杭州_人口杭州_地铁...南京_保有量南京_人口...201521090085...180820...2016225915120...185825...........................第二步数据清洗与标准化。这是决定成败的“脏活累活”。我们将用pandas完成import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler # 1. 读取原始数据 df pd.read_csv(data_raw.csv) df.set_index(year, inplaceTrue) # 2. 定义目标地区和原材料地区列表 target_region 杭州 predictor_vars [_人口, _地铁, _人均GDP, _公交客运总量] # 注意这是变量后缀 donor_regions [宁波, 温州, 绍兴, 嘉兴, 湖州, 金华, 南京, 合肥, 南昌] # 3. 构建预测变量矩阵 X (所有地区所有年份) # 创建一个空的DataFrame来存放所有预测变量 X_list [] for region in [target_region] donor_regions: for var in predictor_vars: col_name f{region}{var} if col_name in df.columns: X_list.append(df[col_name]) X pd.concat(X_list, axis1) # 4. 对X进行Z-score标准化关键 scaler StandardScaler() X_scaled pd.DataFrame( scaler.fit_transform(X), indexX.index, columnsX.columns ) # 5. 分离出目标地区的结果变量 Y (2015-2023年) Y_target df[f{target_region}_保有量] # 6. 定义干预时间点2021年 treatment_year 2021 pre_treatment_years list(range(2015, treatment_year)) # [2015, 2016, 2017, 2018, 2019, 2020] post_treatment_years list(range(treatment_year, 2024)) # [2021, 2022, 2023] print(f干预前年份: {pre_treatment_years}) print(f干预后年份: {post_treatment_years}) print(f标准化后X矩阵形状: {X_scaled.shape})这段代码完成了数据的加载、结构化、标准化。注意scaler.fit_transform(X)是对整个矩阵X包含了所有地区、所有变量进行的确保了尺度的一致性。此时X_scaled就是一个干净的、可供模型直接食用的“食材”。4.3 模型训练与核心结果生成见证“合成”的诞生现在我们手握干净的数据是时候召唤SyntheticControl这个“炼金术士”了。这一步的代码异常简洁但其背后是强大的优化引擎在高速运转。from syntheticcontrol import SyntheticControl # 1. 初始化模型 # treatment_time 是干预发生的年份2021 # predictors 是我们之前定义的变量后缀列表 model SyntheticControl( treatment_timetreatment_year, predictorspredictor_vars, donor_pooldonor_regions, target_nametarget_region ) # 2. 训练模型喂给它标准化后的X和Y_target # 注意fit()方法会自动识别哪些列属于目标哪些属于donor model.fit(X_scaled, Y_target) # 3. 获取核心结果 # a) 合成控制组的权重 weights model.weights_ print(合成控制组权重:) for region, weight in zip(donor_regions, weights): print(f {region}: {weight:.3f}) # b) 预测的反事实结果即合成组在所有年份的保有量 Y_synthetic model.predict(X_scaled) # c) 计算干预效应真实值 - 合成值 effect Y_target - Y_synthetic # d) 将结果整合到一个DataFrame中便于后续分析和绘图 results_df pd.DataFrame({ Year: Y_target.index, Actual_Hangzhou: Y_target.values, Synthetic_Hangzhou: Y_synthetic.values, Effect: effect.values }) results_df.set_index(Year, inplaceTrue) print(\n干预效应摘要 (2021-2023):) print(results_df.loc[post_treatment_years].round(2))运行这段代码后你会立刻看到两样东西权重输出例如南京: 0.382,宁波: 0.291,合肥: 0.157... 这些数字告诉你模型认为南京是“最像杭州”的城市贡献了近四成的合成成分。效应摘要表显示2021、2022、2023年杭州真实保有量比合成组高出多少万辆。如果三年都是正值且逐年扩大那初步证据就指向政策有效。但这仅仅是开始。真正的价值在于接下来的可视化诊断。4.4 可视化诊断与结果解读让数据自己开口说话一张好的图胜过千行代码。我们将用matplotlib和seaborn绘制三张决定性的图表。第一张干预前拟合图The Golden Plotimport matplotlib.pyplot as plt import seaborn as sns plt.figure(figsize(12, 6)) # 绘制所有原材料地区的原始轨迹灰色细线 for region in donor_regions: plt.plot(df.index, df[f{region}_保有量], colorlightgray, linewidth0.8, alpha0.7) # 绘制真实目标组杭州轨迹蓝色粗实线 plt.plot(results_df.index, results_df[Actual_Hangzhou], labelActual Hangzhou, colorblue, linewidth2.5) # 绘制合成控制组轨迹橙色粗虚线 plt.plot(results_df.index, results_df[Synthetic_Hangzhou], labelSynthetic Hangzhou, colororange, linewidth2.5, linestyle--) # 添加干预时间点的垂直线 plt.axvline(xtreatment_year, colorred, linestyle:, linewidth2, labelfTreatment ({treatment_year})) plt.title(Pre- and Post-Treatment Fit: Hangzhou Motor Vehicle Ownership, fontsize14) plt.xlabel(Year) plt.ylabel(Ownership (10,000 vehicles)) plt.legend() plt.grid(True, alpha0.3) plt.show()这张图是你的“定心丸”。请把眼睛聚焦在2015-2020年干预前的蓝色实线和橙色虚线。它们应该像一对双胞胎紧紧相依。如果在这段区间内它们的差距超过了2%你就需要停下来重新审视数据或变量。第二张干预效应图The Effect Plotplt.figure(figsize(12, 6)) # 绘制效应曲线黑色粗线 plt.plot(results_df.index, results_df[Effect], labelEstimated Effect, colorblack, linewidth2.5) # 添加干预时间点的垂直线 plt.axvline(xtreatment_year, colorred, linestyle:, linewidth2, labelfTreatment ({treatment_year})) # 添加零线虚线 plt.axhline(y0, colorgray, linestyle--, linewidth1) plt.title(Estimated Causal Effect of Green Travel Subsidy, fontsize14) plt.xlabel(Year) plt.ylabel(Effect on Ownership (10,000 vehicles)) plt.legend() plt.grid(True, alpha0.3) plt.show()这张图揭示了政策的“生命力”。2021年效应为正说明政策立即产生了抑制增长的作用2022、2023年效应持续扩大说明政策效果在累积和深化。如果效应在2022年回落那就要警惕是否存在“政策疲劳”或外部冲击如经济下行导致购车意愿普遍降低。第三张安慰剂检验图The Placebo Plot# 运行安慰剂检验 placebo_results model.placebo_test(n_permutations100) # 绘制所有伪效应灰色细线和真实效应红色粗线 plt.figure(figsize(12, 6)) for i, placebo_effect in enumerate(placebo_results[placebo_effects]): plt.plot(placebo_results[years], placebo_effect, colorlightgray, linewidth0.5, alpha0.5) # 绘制真实效应红色粗线 plt.plot(placebo_results[years], placebo_results[true_effect], labelTrue Effect (Hangzhou), colorred, linewidth2.5) plt.title(Placebo Test: True Effect vs. 100 Placebo Effects, fontsize14) plt.xlabel(Year) plt.ylabel(Estimated Effect) plt.legend() plt.grid(True, alpha0.3) plt.show() # 打印p-value p_value placebo_results[p_value] print(f\nPlacebo Test p-value: {p_value:.3f}) if p_value 0.05: print(Conclusion: The estimated effect is statistically significant.) else: print(Conclusion: The estimated effect is not statistically significant.)这张图是你的“护城河”。如果红色粗线在所有灰色细线之上且p-value 0.05那么你就可以非常自信地向管理层汇报“我们有95%的把握确认这项补贴政策实实在在地降低了杭州的机动车保有量增长。”5. 常见问题与排查技巧实录那些踩过的坑都成了你的垫脚石5.1 “拟合图在干预前就歪了”—— 数据质量问题的终极显影这是新手遇到的第一个、也是最令人沮丧的问题。你满怀期待地跑完代码结果一看拟合图2015-2020年蓝色实线和橙色虚线之间隔着一道鸿沟。别慌这恰恰是合成控制最伟大的地方——它不是在掩盖问题而是在精准地暴露问题。这道鸿沟就是数据世界给你发来的“故障警报”。排查清单检查时间点对齐最常见错误。确认df.index年份是否