Skip to content

🇬🇧 English · 🇪🇸 Español

Reproducible studies and comparisons

puremacro.study records one explicit statistical study: selected inputs, transformations, units, identification assumptions, estimator settings, results, diagnostics, dependency versions and checksums. It reuses the existing LP and recursive VAR estimators, report formatters and portable runtime frame codec. It does not infer a causal interpretation or certify an economic model.

Run and compare local projections

This complete offline example uses synthetic observations. Its exogeneity assumption is declared for illustration; the workflow does not establish it.

from dataclasses import replace
import numpy as np
import pandas as pd
from puremacro.study import (
    VariableSpec, StudySpec, run_study, compare_studies,
    save_study, load_study,
)

rng = np.random.default_rng(173)
observations = rng.normal(size=(240, 3))
for t in range(1, len(observations)):
    observations[t] += 0.4 * observations[t - 1]
data = pd.DataFrame(
    observations,
    columns=["shock", "output", "price"],
    index=pd.period_range("1960Q1", periods=240, freq="Q"),
)
data.attrs = {"source": "synthetic demonstration", "data_seed": 173}

spec = StudySpec(
    name="LP, two lags", method="lp",
    variables=(
        VariableSpec("shock", "percentage points"),
        VariableSpec("output", "index points"),
        VariableSpec("price", "index points"),
    ),
    responses=("output",), shock="shock", controls=("price",),
    frequency="Q", identification="observed_exogenous",
    lags=2, horizon=8, shock_size=0.25, ci=0.90,
)
baseline = run_study(data, spec)
alternative = run_study(data, replace(spec, name="LP, four lags", lags=4))
comparison = compare_studies(baseline, alternative)
print(comparison.to_frame())
print(baseline.audits)

# A new directory only: existing results are not overwritten.
baseline.write_report("study_output/lp_two_lags")
# Or retain the complete record in memory, including in a browser kernel.
payload = save_study(baseline)
restored = load_study(payload)
assert restored.manifest == baseline.manifest

write_report writes Markdown, LaTeX, Typst and CSV tables, manifest.json, and the lossless study.pmstudy archive. Markdown and the manifest include methods, assumptions, effective samples and audit limits. CSV is a presentation format; use the archive to preserve index types and supported DataFrame.attrs. to_json() contains the manifest and output tables, not the input frames. Both StudyResult and StudyComparison are frozen records; returned frames and manifest dictionaries are independent copies.

State the transformations, units and time grid

Every input column needs a VariableSpec. Available transformations are identity, log, diff, and logdiff; scale multiplies the transformed series. The declared unit describes that final quantity. For example, VariableSpec("output", "percent log growth", "logdiff", 100) computes 100 * diff(log(output)). The caller supplies positive output levels. There is no variable-name heuristic for percent conversion or annualization.

The input index must be a unique, increasing, consecutive PeriodIndex or DatetimeIndex. frequency="Q" describes quarterly periods; for timestamp indices use the corresponding calendar offset, such as "QS" or "MS". Quarter/month-end aliases accept the supported pandas spelling. A missing period or nonfinite value is an error; time is never compressed by dropping missing rows. Extra undeclared columns are errors too.

sample_start and sample_end, if supplied, must identify observations on that grid. Selection precedes transformation. Differencing removes exactly the first selected observation from every series; it does not borrow a value from outside the selected sample. The archive retains the selected raw inputs, not rows outside the requested sample. Source, vintage and retrieval notes can be stored in data.attrs; unsupported metadata objects raise an error instead of being silently discarded.

Understand the estimator contracts

Setting method="lp" method="var_cholesky"
Identification observed_exogenous: the observed shock is exogenous conditional on regressors, as an assumption recursive: declared contemporaneous recursive ordering
Response at horizon h y[t+h] - y[t-1] Level impulse response at h
Shock size Multiple of the observed shock's transformed unit Named shock variable's impact in its transformed unit
lags=p Lags 1–p of the individual response, shock and controls; contemporaneous controls and a constant Lags 1–p of every ordered variable and a constant; p ≥ 1
Effective sample First shock date is max(1,p); last is T-h-1 Dates p through T−1 for the fitted VAR
Uncertainty Pointwise normal HAC intervals; Bartlett bandwidth h+1 Optional residual bootstrap pointwise percentile intervals

Horizons are inclusive: horizon=8 returns 0 through 8. LP responses have different estimation windows by horizon. Changing lags can also change fitted observations. n_obs is reported per result, and effective_by_horizon in the manifest records the shock/regressor dates. Identical raw and transformed input windows do not imply identical fitted observations.

LP response, shock and control names must be distinct. With multiple responses, each LP uses its own response lags; other responses are not silently inserted as controls. n_boot must be zero for LP. For VAR, ordering contains every variable exactly once and controls is empty:

recursive = replace(
    spec, name="Recursive VAR", method="var_cholesky",
    identification="recursive", controls=(),
    ordering=("price", "shock", "output"), n_boot=199, seed=71,
)
var_result = run_study(data, recursive)

The VAR normalizes the entire response by shock_size / P[k,k], where P is the point estimate's Cholesky factor and k is the shock's ordered position. Bootstrap intervals use this fixed normalization: they do not renormalize every draw to the same impact. Negative shocks reverse interval endpoints correctly. n_boot=0 omits interval columns; it does not fabricate zero-width bands. Failed bootstrap draws and estimated VAR instability remain visible in the audit table. Nonfinite exported estimates or intervals raise an error.

compare_studies accepts at least two uniquely named studies with matching method, identification, ordering, horizon, variable definitions, response convention, shock size and units, and identical raw/transformed data and metadata. Lags, confidence level, seed and bootstrap count may differ and are recorded. Mixed LP/VAR comparisons are rejected: an observed exogenous shock with a cumulative response is not automatically the same estimand as a recursively identified innovation with a level response. The API does not add a cosmetic overlay that conceals this difference.

Preserve and reload evidence safely

save_study(result, "study.pmstudy") writes a new file; save_study(result) returns bytes. load_study(path_or_bytes, max_bytes=64 * 1024 * 1024) validates the exact archive inventory, schema, frame and manifest SHA-256 checksums, units, sample counts and finite numerical values. ZIP expansion and NPY array shapes are bounded before decoding. It uses JSON and the version-2 runtime NPZ codec, including supported frame metadata. It neither extracts archive paths nor unpickles objects, imports callbacks or executes an estimator. No Parquet engine, cloud account or network connection is required.

Checksums detect changed content; they do not authenticate authors or prove the correctness of a deliberately rehashed result. Seeds and numerical package versions are retained. Bitwise rerun equality is expected only within the same numerical environment; different BLAS libraries or package versions can change floating-point results. The diagnostic statuses are passed, failed, not_checked and unsupported; identification remains not_checked.

Record an evaluated model or scenario

ScenarioSpec, ScenarioResult and record_scenario record existing DSGE, dynamic-programming, HANK, structural-fit or static scenario outputs. They do not run a second solver. Source is inert text; parameters, evaluated outputs, input tables, audit statuses and optional evidence frames are retained.

The following illustrates an explicitly calibrated response path, without claiming that its parameters were estimated from the example observations:

from puremacro.study import ScenarioSpec, record_scenario

scenario_spec = ScenarioSpec(
    name="Calibrated response illustration", model="declared geometric response",
    variables=spec.variables, responses=spec.responses, shock=spec.shock,
    frequency=spec.frequency, identification=spec.identification,
    response_convention="y[t+h]-y[t-1]",
    horizon=spec.horizon, shock_size=spec.shock_size,
)
outputs = pd.DataFrame({
    "response": ["output"] * (spec.horizon + 1),
    "horizon": range(spec.horizon + 1),
    "estimate": [0.1 * 0.7**h for h in range(spec.horizon + 1)],
})
scenario = record_scenario(
    outputs, scenario_spec,
    inputs=baseline.inputs, transformed_inputs=baseline.data,
    model_source="response[h] = 0.1 * persistence**h; pre-shock baseline = 0",
    parameters={"persistence": 0.7, "impact": 0.1},
    audits=pd.DataFrame([{
        "check": "economic_identification", "status": "not_checked",
        "detail": "External shock equivalence is assumed for this illustration.",
    }]),
    provenance={"purpose": "calibrated illustration; parameters not estimated"},
)
overlay = compare_studies(
    baseline, scenario,
    comparison_note="Assume the model innovation maps to the declared observed "
                    "exogenous shock and the same pre-shock baseline; illustrative only.",
)
scenario.write_report("study_output/calibrated_response")

Outputs contain exactly response, horizon, estimate, in response-major order and horizons 0..H. Units are supplied by variables. Audit tables have check, status, detail; failed, unsupported or not-checked rows are retained. parameters contains finite real scalars; evidence={"name": dataframe} can store general calibration matrices, actual full joint moment covariance, fit/held-out targets, distribution/state paths or separate draw records. The recorder never constructs a covariance from marginal LP bands.

inputs may be a static model/calibration table. transformed_inputs is mandatory when variable definitions declare a nonidentity transform or scale; the recorder does not infer or recompute model data transformations. For a static input-output scenario, horizon=0, frequency="annual_static", identification="calibrated_input_output_scenario" and response_convention="static_level_change" are honest explicit labels. No empirical date index is required for a standalone scenario.

Any comparison containing scenarios requires comparison_note. It also checks exact shock/response units and transformations, shock size, identification, frequency, horizon and response convention, and identical raw/transformed input snapshots. A note cannot override an inconsistent contract. These checks verify declared comparability; they do not prove economic equivalence. Scenario tables do not fabricate empirical observation counts or confidence intervals.

save_study/load_study distinguish empirical and scenario archive tags. Scenario reports additionally include model_source.txt; lossless evidence and provenance remain inside the archive. Unsupported frame structures, such as named MultiIndex column levels or subclass-specific result attributes, are rejected if the runtime codec cannot preserve them. Convert deliberately to a plain table and retain coordinate definitions in provenance. All numeric evidence must be finite; retain failed-draw status separately instead of hiding or replacing failed numerical output.

This workflow does not automate IV identification, structural estimation or model/scenario selection. To construct correlated empirical targets and fit a model callback, use the existing structural bridge and research workflows, then record their actual outputs.