skills/07-economics/synthetic-control/SKILL.md
Use this Skill for synthetic control causal inference: donor pool selection, weight optimization via scipy, pre-period balance, post-period gap plot, placebo tests, and permutation p-values.
npx skillsauth add xjtulyc/awesome-rosetta-skills synthetic-controlInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
TL;DR — Construct a data-driven counterfactual for a single treated unit using a weighted combination of untreated donor units. Estimate policy effects as the post-treatment gap between actual and synthetic outcomes, and assess significance via permutation placebo tests.
| Situation | Recommendation | |---|---| | Single treated unit (country, state, firm) | Primary use case for synthetic control | | Comparative case studies | Strong alternative to simple DiD | | Long pre-treatment time series (T_pre ≥ 10) | Required for reliable weight estimation | | Small donor pool (5–40 donors) | Feasible; quality depends on fit | | Staggered or multiple treated units | Use generalized synthetic control instead | | Short pre-treatment window (< 5 periods) | Prefer DiD or regression discontinuity |
The synthetic control method (Abadie, Diamond, and Hainmueller 2010) is ideal when you study a large-scale policy intervention affecting a single aggregate unit — a country enacting a trade reform, a state adopting a minimum wage law, a firm receiving a large government subsidy — and you have panel data on similar untreated units over a long pre-treatment window.
Key advantages over DiD:
Let unit 1 be the treated unit and units 2, …, J+1 be the donor pool. For each pre-treatment period t ≤ T_0, we observe outcome Y_{jt}. For the treated unit in the post-treatment period t > T_0, we want to estimate the counterfactual Y_{1t}(0).
Synthetic control estimator:
Ŷ_{1t}(0) = Σ_{j=2}^{J+1} w_j* × Y_{jt}
where weights W* = (w_2*, …, w_{J+1}*) are chosen to minimize:
MSPE_pre = Σ_{t=1}^{T_0} (Y_{1t} − Σ_j w_j Y_{jt})²
subject to: w_j ≥ 0 for all j and Σ_j w_j = 1.
Treatment effect estimate:
α̂_{1t} = Y_{1t} − Ŷ_{1t}(0) for t > T_0
Gap statistic:
Gap_t = Y_{1t} − Ŷ_{1t}(0)
A positive post-period gap indicates a positive treatment effect.
Two approaches exist for constructing W*:
Outcome path matching — minimize MSPE over all pre-treatment outcome periods. Simple and often sufficient for good fit.
Predictor + outcome matching — minimize a weighted distance over pre-treatment outcome averages AND covariate predictors (GDP per capita, population, etc.) using an outer optimization over predictor weights V.
This Skill implements outcome path matching (simpler, more transparent), which is the most common choice in practice.
Because there is no asymptotic distribution to rely on with a single treated unit, inference uses in-space placebos:
A p-value ≤ 0.10 is considered significant in standard practice.
# Create a dedicated conda environment
conda create -n synth python=3.11 -y
conda activate synth
# Install dependencies
pip install pandas>=1.5 numpy>=1.23 scipy>=1.9 matplotlib>=3.6
# Verify installation
python -c "import pandas, numpy, scipy, matplotlib; print('All packages imported successfully')"
No proprietary data access is required. The examples below use synthetic data that are generated within the script itself.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import minimize, LinearConstraint
np.random.seed(42)
def generate_synthetic_panel(
n_donors: int = 20,
n_periods: int = 30,
treatment_period: int = 20,
true_effect: float = 3.0,
) -> pd.DataFrame:
"""
Generate a panel dataset for synthetic control exercises.
The treated unit (unit_id=0) follows a linear combination of donors in
the pre-treatment period plus a post-treatment jump of `true_effect`.
Args:
n_donors: Number of untreated donor units.
n_periods: Total number of time periods.
treatment_period: First post-treatment period (0-indexed).
true_effect: True average treatment effect post-treatment.
Returns:
DataFrame with columns: unit_id, period, outcome, treated.
"""
# Donor outcomes: AR(1) process with unit-specific means
unit_means = np.random.uniform(5, 15, n_donors)
donor_outcomes = np.zeros((n_donors, n_periods))
for j in range(n_donors):
donor_outcomes[j, 0] = unit_means[j] + np.random.normal(0, 0.5)
for t in range(1, n_periods):
donor_outcomes[j, t] = (
0.7 * donor_outcomes[j, t - 1]
+ 0.3 * unit_means[j]
+ np.random.normal(0, 0.5)
)
# True synthetic weights: use first 4 donors with equal weight
true_weights = np.zeros(n_donors)
true_weights[:4] = 0.25
# Treated unit: weighted combination of donors pre-treatment
treated_outcomes = np.zeros(n_periods)
for t in range(n_periods):
treated_outcomes[t] = (
true_weights @ donor_outcomes[:, t]
+ np.random.normal(0, 0.3)
+ (true_effect if t >= treatment_period else 0.0)
)
rows = []
# Treated unit (unit_id=0)
for t in range(n_periods):
rows.append({"unit_id": 0, "period": t, "outcome": treated_outcomes[t], "treated": 1})
# Donor units
for j in range(n_donors):
for t in range(n_periods):
rows.append({"unit_id": j + 1, "period": t, "outcome": donor_outcomes[j, t], "treated": 0})
return pd.DataFrame(rows)
def build_matrices(df: pd.DataFrame, treatment_period: int, treated_id: int = 0):
"""
Extract treated outcome vector and donor outcome matrix.
Args:
df: Panel DataFrame with columns unit_id, period, outcome.
treatment_period: Index of first post-treatment period.
treated_id: unit_id of the treated unit.
Returns:
y_treated (T,), Y_donors (J, T), pre_mask (T,), post_mask (T,)
"""
periods = sorted(df["period"].unique())
T = len(periods)
y_treated = np.array([
df.loc[(df["unit_id"] == treated_id) & (df["period"] == t), "outcome"].values[0]
for t in periods
])
donor_ids = sorted(df.loc[df["unit_id"] != treated_id, "unit_id"].unique())
Y_donors = np.array([
[df.loc[(df["unit_id"] == j) & (df["period"] == t), "outcome"].values[0]
for t in periods]
for j in donor_ids
]) # shape: (J, T)
pre_mask = np.array([t < treatment_period for t in periods])
post_mask = ~pre_mask
return y_treated, Y_donors, pre_mask, post_mask, donor_ids
def fit_synthetic_control(
y_treated: np.ndarray,
Y_donors: np.ndarray,
pre_mask: np.ndarray,
) -> np.ndarray:
"""
Find donor weights W* by minimizing pre-period MSPE.
Objective: min_w Σ_{t in pre} (Y_{treated,t} - w @ Y_donors[:,t])^2
Subject to: w_j >= 0 for all j, sum(w) = 1.
Args:
y_treated: Treated unit outcomes, shape (T,).
Y_donors: Donor outcomes, shape (J, T).
pre_mask: Boolean mask for pre-treatment periods, shape (T,).
Returns:
Optimal weight vector w*, shape (J,).
"""
J = Y_donors.shape[0]
y_pre = y_treated[pre_mask] # (T_pre,)
Y_pre = Y_donors[:, pre_mask] # (J, T_pre)
def mspe(w):
synthetic_pre = w @ Y_pre # (T_pre,)
return np.mean((y_pre - synthetic_pre) ** 2)
def mspe_grad(w):
synthetic_pre = w @ Y_pre
residuals = synthetic_pre - y_pre
return 2 * (Y_pre @ residuals) / len(y_pre)
# Constraints: sum(w) = 1, w >= 0
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}]
bounds = [(0.0, 1.0)] * J
w0 = np.ones(J) / J
result = minimize(
mspe,
w0,
jac=mspe_grad,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-10, "maxiter": 2000},
)
if not result.success:
print(f"Warning: optimizer did not converge — {result.message}")
w_star = result.x
w_star = np.clip(w_star, 0, None)
w_star /= w_star.sum() # re-normalize for numerical safety
return w_star
def compute_gap(
y_treated: np.ndarray,
Y_donors: np.ndarray,
w_star: np.ndarray,
) -> np.ndarray:
"""
Compute the treatment gap: actual minus synthetic outcome.
Args:
y_treated: Treated outcomes, shape (T,).
Y_donors: Donor outcomes, shape (J, T).
w_star: Fitted weights, shape (J,).
Returns:
Gap series of shape (T,).
"""
synthetic = w_star @ Y_donors # (T,)
return y_treated - synthetic
def plot_gap(
y_treated: np.ndarray,
Y_donors: np.ndarray,
w_star: np.ndarray,
pre_mask: np.ndarray,
donor_ids: list,
output_path: str = None,
) -> None:
"""
Plot actual vs synthetic outcome and the treatment gap.
Args:
y_treated: Treated outcomes, shape (T,).
Y_donors: Donor outcomes, shape (J, T).
w_star: Fitted weights, shape (J,).
pre_mask: Boolean mask for pre-treatment periods.
donor_ids: List of donor unit IDs for the legend.
output_path: If provided, save figure to this path.
"""
T = len(y_treated)
periods = np.arange(T)
T_0 = np.sum(pre_mask)
synthetic = w_star @ Y_donors
fig, axes = plt.subplots(2, 1, figsize=(10, 8), sharex=True)
# Panel A: outcome paths
ax = axes[0]
ax.plot(periods, y_treated, color="#C0392B", linewidth=2, label="Treated unit")
ax.plot(periods, synthetic, color="#2980B9", linewidth=2, linestyle="--", label="Synthetic control")
for j in range(Y_donors.shape[0]):
ax.plot(periods, Y_donors[j], color="gray", linewidth=0.5, alpha=0.4)
ax.axvline(T_0 - 0.5, color="black", linestyle=":", linewidth=1.2, label="Treatment onset")
ax.set_ylabel("Outcome")
ax.set_title("Synthetic Control: Actual vs Synthetic")
ax.legend()
# Panel B: gap
gap = y_treated - synthetic
ax2 = axes[1]
ax2.plot(periods, gap, color="#8E44AD", linewidth=2)
ax2.axhline(0, color="black", linewidth=0.8, linestyle="--")
ax2.axvline(T_0 - 0.5, color="black", linestyle=":", linewidth=1.2)
ax2.fill_between(periods[~pre_mask], gap[~pre_mask], 0,
alpha=0.25, color="#8E44AD", label="Post-treatment gap")
ax2.set_xlabel("Period")
ax2.set_ylabel("Gap (Actual − Synthetic)")
ax2.set_title("Treatment Effect Gap")
ax2.legend()
fig.tight_layout()
if output_path:
fig.savefig(output_path, dpi=150)
print(f"Saved gap plot to {output_path}")
plt.show()
def compute_rmspe_ratio(
y_unit: np.ndarray,
Y_donors_excl: np.ndarray,
pre_mask: np.ndarray,
) -> tuple:
"""
Fit synthetic control for a single unit and return pre/post RMSPE ratio.
Args:
y_unit: Outcome for the focal unit, shape (T,).
Y_donors_excl: Donor pool excluding this unit, shape (J-1, T).
pre_mask: Boolean mask for pre-treatment periods.
Returns:
Tuple of (rmspe_pre, rmspe_post, rmspe_ratio, w_star).
"""
w_star = fit_synthetic_control(y_unit, Y_donors_excl, pre_mask)
gap = compute_gap(y_unit, Y_donors_excl, w_star)
rmspe_pre = np.sqrt(np.mean(gap[pre_mask] ** 2))
rmspe_post = np.sqrt(np.mean(gap[~pre_mask] ** 2))
ratio = rmspe_post / rmspe_pre if rmspe_pre > 0 else np.inf
return rmspe_pre, rmspe_post, ratio, w_star
def in_space_placebo_test(
y_treated: np.ndarray,
Y_donors: np.ndarray,
pre_mask: np.ndarray,
donor_ids: list,
max_pre_rmspe_multiplier: float = 2.0,
output_path: str = None,
) -> dict:
"""
Run in-space placebo tests and compute permutation p-value.
For each donor unit, apply synthetic control as if it were treated.
Compute RMSPE ratios for all units. The p-value is the fraction of
donors (with acceptable pre-period fit) whose ratio exceeds the
treated unit's ratio.
Args:
y_treated: Treated unit outcomes, shape (T,).
Y_donors: Donor outcomes, shape (J, T).
pre_mask: Boolean mask for pre-treatment periods.
donor_ids: Donor unit IDs.
max_pre_rmspe_multiplier: Exclude placebos with pre-RMSPE >
multiplier * treated_pre_rmspe.
output_path: If provided, save distribution plot.
Returns:
Dictionary with keys: treated_ratio, placebo_ratios, p_value, n_placebos.
"""
J = Y_donors.shape[0]
rmspe_pre_treated, rmspe_post_treated, ratio_treated, _ = compute_rmspe_ratio(
y_treated, Y_donors, pre_mask
)
print(f"Treated unit — RMSPE_pre: {rmspe_pre_treated:.4f}, "
f"RMSPE_post: {rmspe_post_treated:.4f}, ratio: {ratio_treated:.4f}")
placebo_ratios = []
placebo_pre_rmspes = []
for j in range(J):
# Treated = donor j; new donor pool = treated + remaining donors
y_placebo = Y_donors[j]
remaining = np.delete(np.arange(J), j)
Y_pool = np.vstack([y_treated.reshape(1, -1), Y_donors[remaining]])
rmspe_pre_j, _, ratio_j, _ = compute_rmspe_ratio(y_placebo, Y_pool, pre_mask)
placebo_ratios.append(ratio_j)
placebo_pre_rmspes.append(rmspe_pre_j)
placebo_ratios = np.array(placebo_ratios)
placebo_pre_rmspes = np.array(placebo_pre_rmspes)
# Filter: only retain placebos with reasonable pre-period fit
threshold = max_pre_rmspe_multiplier * rmspe_pre_treated
keep = placebo_pre_rmspes <= threshold
filtered_ratios = placebo_ratios[keep]
n_placebos = len(filtered_ratios)
# Permutation p-value
p_value = np.mean(filtered_ratios >= ratio_treated)
# Plot distribution
fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(filtered_ratios, bins=15, color="#BDC3C7", edgecolor="white", label="Placebo ratios")
ax.axvline(ratio_treated, color="#C0392B", linewidth=2, label=f"Treated ratio = {ratio_treated:.2f}")
ax.set_xlabel("Post/Pre RMSPE Ratio")
ax.set_ylabel("Count")
ax.set_title(f"In-Space Placebo Distribution — p-value = {p_value:.3f}")
ax.legend()
fig.tight_layout()
if output_path:
fig.savefig(output_path, dpi=150)
print(f"Saved placebo distribution to {output_path}")
plt.show()
return {
"treated_ratio": ratio_treated,
"placebo_ratios": filtered_ratios,
"p_value": p_value,
"n_placebos": n_placebos,
}
When you have pre-treatment predictors (X matrix), you can optimize an outer weight matrix V over predictors and an inner weight matrix W over donors:
def fit_predictor_augmented_sc(
y_treated_pre: np.ndarray,
Y_donors_pre: np.ndarray,
X_treated: np.ndarray,
X_donors: np.ndarray,
n_v_restarts: int = 5,
) -> np.ndarray:
"""
Fit predictor-augmented synthetic control (outer V + inner W optimization).
Minimizes: ||V^{1/2}(X_treated - X_donors @ w)||^2 + ||y_pre - Y_donors_pre @ w||^2
Args:
y_treated_pre: Treated outcome in pre-period, shape (T_pre,).
Y_donors_pre: Donor outcomes in pre-period, shape (J, T_pre).
X_treated: Treated predictor vector, shape (K,).
X_donors: Donor predictors, shape (J, K).
n_v_restarts: Number of random restarts for outer optimization.
Returns:
Optimal weight vector w*, shape (J,).
"""
J, K = X_donors.shape
best_w = None
best_obj = np.inf
for _ in range(n_v_restarts):
v0 = np.random.dirichlet(np.ones(K + 1)) # K predictors + outcome path
def outer_objective(v_raw):
v = np.exp(v_raw) / np.sum(np.exp(v_raw)) # softmax to ensure positive
v_pred = v[:K]
v_out = v[K]
def inner_obj(w):
pred_gap = X_treated - X_donors.T @ w # (K,)
out_gap = y_treated_pre - Y_donors_pre.T @ w # (T_pre,)
return v_pred @ (pred_gap ** 2) + v_out * np.mean(out_gap ** 2)
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}]
bounds = [(0.0, 1.0)] * J
res = minimize(inner_obj, np.ones(J) / J, method="SLSQP",
bounds=bounds, constraints=constraints)
return res.fun
res_outer = minimize(outer_objective, np.log(v0 + 1e-8),
method="Nelder-Mead",
options={"maxiter": 500, "xatol": 1e-6})
if res_outer.fun < best_obj:
best_obj = res_outer.fun
v_best = np.exp(res_outer.x) / np.sum(np.exp(res_outer.x))
v_pred_best = v_best[:K]
v_out_best = v_best[K]
def inner_obj_final(w):
pred_gap = X_treated - X_donors.T @ w
out_gap = y_treated_pre - Y_donors_pre.T @ w
return v_pred_best @ (pred_gap ** 2) + v_out_best * np.mean(out_gap ** 2)
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}]
bounds = [(0.0, 1.0)] * J
res_inner = minimize(inner_obj_final, np.ones(J) / J, method="SLSQP",
bounds=bounds, constraints=constraints)
best_w = np.clip(res_inner.x, 0, None)
best_w /= best_w.sum()
return best_w
# In-time placebo: shift treatment date to an earlier period
def in_time_placebo(
y_treated: np.ndarray,
Y_donors: np.ndarray,
true_treatment_period: int,
placebo_period: int,
) -> float:
"""
Run an in-time placebo test by treating an earlier period as treatment.
If the placebo gap is large, the identifying assumption may be violated.
Args:
y_treated: Full outcome series for treated unit.
Y_donors: Full donor outcomes matrix.
true_treatment_period: Actual treatment start index.
placebo_period: Fake treatment start index (must be < true_treatment_period).
Returns:
Mean post-placebo gap (should be near zero for validity).
"""
assert placebo_period < true_treatment_period, "Placebo period must precede real treatment."
# Use only data strictly before the real treatment
y_sub = y_treated[:true_treatment_period]
Y_sub = Y_donors[:, :true_treatment_period]
pre_mask_sub = np.arange(true_treatment_period) < placebo_period
w_star = fit_synthetic_control(y_sub, Y_sub, pre_mask_sub)
gap_sub = compute_gap(y_sub, Y_sub, w_star)
post_gap = gap_sub[~pre_mask_sub]
mean_placebo_gap = float(np.mean(post_gap))
print(f"In-time placebo (period {placebo_period}): mean gap = {mean_placebo_gap:.4f}")
return mean_placebo_gap
| Error / Issue | Likely Cause | Resolution |
|---|---|---|
| Optimizer does not converge | Poorly scaled outcomes across donors | Standardize all outcome series before optimization |
| All weight placed on 1–2 donors | Highly collinear donor pool | Verify donor pool diversity; add more donors |
| Terrible pre-period fit (RMSPE_pre >> 0) | Treated unit unlike any donor | Reconsider donor pool; use generalized SC |
| p-value = 1.0 (no placebo exceeds treated) | Extremely large post-treatment effect | Check for data errors; verify treatment date |
| p-value = 0 (all placebos exceed treated) | Poor fit — pre-period RMSPE already large | Expand donor pool; check for unit-specific shocks |
| Negative weights from optimizer | Numerical drift in SLSQP | Re-clip and re-normalize; tighten bounds |
| LinAlgError: SVD did not converge | Collinear donor matrix | Drop duplicate or near-duplicate donors |
| KeyError on donor lookup | Mismatched unit_id encoding | Ensure integer unit IDs; reset index before build_matrices |
pysyncon Python package: https://github.com/sdfordham/pysynconsynth package: https://web.stanford.edu/~jhain/synthpage.html# Full synthetic control pipeline
df = generate_synthetic_panel(n_donors=20, n_periods=30, treatment_period=20, true_effect=3.0)
T_0 = 20
y_treated, Y_donors, pre_mask, post_mask, donor_ids = build_matrices(df, T_0, treated_id=0)
# Step 1: optimize weights
w_star = fit_synthetic_control(y_treated, Y_donors, pre_mask)
top_donors = sorted(zip(donor_ids, w_star), key=lambda x: -x[1])[:5]
print("Top 5 donors by weight:")
for did, ww in top_donors:
print(f" Unit {did}: {ww:.4f}")
# Step 2: pre-period RMSPE
gap = compute_gap(y_treated, Y_donors, w_star)
rmspe_pre = np.sqrt(np.mean(gap[pre_mask] ** 2))
rmspe_post = np.sqrt(np.mean(gap[post_mask] ** 2))
print(f"\nRMSPE_pre = {rmspe_pre:.4f}")
print(f"RMSPE_post = {rmspe_post:.4f}")
print(f"RMSPE_ratio = {rmspe_post / rmspe_pre:.4f}")
# Step 3: gap plot
plot_gap(y_treated, Y_donors, w_star, pre_mask, donor_ids, output_path="gap_plot.png")
# Step 4: permutation p-value
results = in_space_placebo_test(
y_treated, Y_donors, pre_mask, donor_ids, output_path="placebo_dist.png"
)
print(f"\nPermutation p-value: {results['p_value']:.3f} (n_placebos = {results['n_placebos']})")
def leave_one_out_sensitivity(
y_treated: np.ndarray,
Y_donors: np.ndarray,
pre_mask: np.ndarray,
donor_ids: list,
output_path: str = None,
) -> pd.DataFrame:
"""
Leave-one-out sensitivity: remove each donor and refit. Checks robustness.
Args:
y_treated: Treated outcomes, shape (T,).
Y_donors: Donor outcomes, shape (J, T).
pre_mask: Pre-treatment mask.
donor_ids: List of donor unit IDs.
output_path: Save figure if provided.
Returns:
DataFrame with columns: dropped_donor, rmspe_pre, rmspe_post, mean_post_gap.
"""
T = len(y_treated)
periods = np.arange(T)
T_0 = np.sum(pre_mask)
J = Y_donors.shape[0]
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(periods, y_treated, color="#C0392B", linewidth=2.5, label="Treated unit", zorder=5)
records = []
for j in range(J):
remaining = np.delete(np.arange(J), j)
Y_loo = Y_donors[remaining]
w_loo = fit_synthetic_control(y_treated, Y_loo, pre_mask)
synthetic_loo = w_loo @ Y_loo
gap_loo = y_treated - synthetic_loo
rmspe_pre_j = np.sqrt(np.mean(gap_loo[pre_mask] ** 2))
rmspe_post_j = np.sqrt(np.mean(gap_loo[~pre_mask] ** 2))
ax.plot(periods, synthetic_loo, color="steelblue", linewidth=0.8, alpha=0.5)
records.append({
"dropped_donor": donor_ids[j],
"rmspe_pre": rmspe_pre_j,
"rmspe_post": rmspe_post_j,
"mean_post_gap": float(np.mean(gap_loo[~pre_mask])),
})
# Full model synthetic
w_full = fit_synthetic_control(y_treated, Y_donors, pre_mask)
ax.plot(periods, w_full @ Y_donors, color="#2980B9", linewidth=2.5,
linestyle="--", label="Full synthetic control", zorder=5)
ax.axvline(T_0 - 0.5, color="black", linestyle=":", linewidth=1.2, label="Treatment onset")
ax.set_xlabel("Period")
ax.set_ylabel("Outcome")
ax.set_title("Leave-One-Out Sensitivity")
ax.legend()
fig.tight_layout()
if output_path:
fig.savefig(output_path, dpi=150)
print(f"Saved LOO sensitivity plot to {output_path}")
plt.show()
return pd.DataFrame(records)
# Run leave-one-out sensitivity
loo_df = leave_one_out_sensitivity(
y_treated, Y_donors, pre_mask, donor_ids, output_path="loo_sensitivity.png"
)
print("\nLeave-one-out sensitivity summary:")
print(loo_df.describe().round(4))
| Version | Date | Change | |---|---|---| | 1.0.0 | 2026-03-18 | Initial release — weight optimization, gap plot, in-space placebo, LOO sensitivity |
tools
R research package development with devtools, roxygen2 documentation, testthat testing, CRAN submission, and vignette creation for statistical methods.
development
Reproducible research reporting with Quarto covering parameterized reports, multi-format output, inline computation, and journal article templates.
development
Python research package development with pyproject.toml, testing with pytest, documentation with Sphinx, and publishing to PyPI for academic software.
development
Write, compile, and submit LaTeX papers: IMRaD structure, key packages, bibliography management, arXiv preparation, and common error fixes.