All notable changes to mlsynth are recorded here. Format loosely follows
Keep a Changelog; per the result-migration
Definition of Done (agents/agents_results.md, gate F), every estimator
migrated onto the two-family result contract gets an entry describing what it
now returns and the back-compat guarantee.
-
outer_restartsandouter_restart_seedonSparseSCConfig: extra starting points for the outer V-solve at each lambda, drawn log-normally around the MATLAB initialiser, withouter_restartsdefaulting to 4.The outer V-objective is not convex, and its stationary points are cheap to reach for a reason unrelated to fit. On a face of the donor simplex where only a few donors are active,
w*(v)has that many degrees of freedom minus one, so the gradient says nothing about the donors that are out; where one donor is active it is identically zero. A single start settles at whichever such point it is nearest. On Vives-i-Bastida's own 40-predictor California specification that is a two-donor point with a training loss of 77.42, against the 1.45 his stored V attains, and an ATT of -29.0 where the paper reports -18.2. The heuristic starts1and0.1 * 1, the warm start from the neighbouring lambda, Nelder-Mead and basinhopping all return the same 77.42; four random draws reach -18.6. Feeding the author's stored V through the same pipeline reproduces -18.206, which is what localises the fault to the outer solve and not to the data, the inner QP or the lambda selection.On the Prop 99 benchmark the ATT moves from a value that varied across BLAS kernels to -18.06 against the paper's -18.2. The sweep runs roughly
1 + outer_restartstimes as long.outer_restarts=0does not reproduce the previous behaviour exactly. The restart feature rides on the fix that ranks candidate starts on the objective recomputed at the returned point instead of on scipy'sres.fun, and that ranking runs at every restart count, zero included. Measured across 25 factor panels withouter_restarts=0: 23 give a bit-identical V, 2 do not. On seed 7 the selected lambda moves from 1 to 0.398, on seed 19 from 1 to 0. Both are cases whereres.funhad ranked a worse point first.Raising
outer_restartsdoes not lower the outer objective at every grid point, and no test claims it does. A better solution at one lambda becomes the champion and the warm start for the next, so a strictly better solve upstream can move a later one into a different basin -- measured at 1.75632 against 1.75548 on a four-point grid. The per-solve ordering does hold and is asserted. -
SparseSCnow populatesmethod_details.parameters_used, which was never filled: the outer loss window, the restart count and seed,robust_selection,warm_startand the selected lambda. Which critical point a non-convex solve reached is a property of the run a caller may need to act on.
-
The general convex solvers moved out of
mlsynth.utils.bilevelinto a newmlsynth.utils.solverspackage:active_set,minnorm,simplex,nnls,accelerateandridge_augment.bilevel.ridge_inferencemoved tomlsynth.utils.conformal.ridge_inference, joininginversionandrefit, andbilevel.jackknife_plustomlsynth.utils.jackknife_plus.penalizedstays inbilevel, which is where it belongs.None of the six moved solvers reaches for the V machinery, while 56 modules import
active_set, 28minnorm, 24simplexand 22ridge_augment. The typed layer inmlsynth.utils.weights, this library's general interface for weight solving, imported from one estimator family's package.mlsynth.utils.bilevelno longer re-exports the moved symbols, sofrom mlsynth.utils.bilevel import solve_simplex_qpand the other moved names raiseImportError. Import them frommlsynth.utils.solvers.mlsynth.utils.fscm_helpers.bilevelis a different package and is unchanged.
-
mlsynth.utils.sparse_sc_helpers.optimization.default_v20returned all-NaN for a single-donor panel. With one donorddof=1leaves the predictor standard deviation undefined, and the guard folded onlysd == 0, so the NaN passed into the start point. Non-finite standard deviations now fold to 1 alongside zero. -
The
optimizationmodule docstring labelledouter_loss_window="validation"as the paper default whenSparseSCConfigselects"training". -
fgrc_n_randomandfgrc_nstartonCLUSTERSCConfig: the two restart counts for fGRC clustering (cluster_method="fgrc"), both defaulting to 40, which is what the port already used. They were not reachable from the config, so a user whose cluster assignment moved between runs had no way to spend more restarts on it.Yamamoto and Hwang (2017, Section 5) report that the number of local optima of the fGRC objective varies with the data condition and recommend implementing the method with many random initial starts.
fgrc_n_randomrestarts the loading matrix;fgrc_nstartrestarts the k-means step within each of those; the lowest-loss solution is retained. The defaults are more generous than the authors' own R package, whoseN.randomdefaults to 1.The restarts minimise over starting points, so raising them cannot raise the retained loss, and a test asserts that ordering instead of a fixed number. Passing the defaults explicitly reproduces not passing them, on both the ATT and the cluster labels.
-
mlsynth.utils.clustersc_helpers.rpca.selection: the number-of-clusters stage of Yamamoto and Hwang (2017) Algorithm 1, which neither this port nor the authors' own R package previously implemented -- as a diagnostic. It is deliberately not a configuration option:fgrc_kstays whatever you set, and nothing inCLUSTERSCConfigreaches the rule.The rule is a self-consistency check. For a candidate K the method is fitted, the Gap statistic (Tibshirani, Walther and Hastie 2001) is computed on the resulting component scores over a wider grid of k, and K is accepted when
argmax_k Gap(k | L_C, L_D, K) = K. Two measurements say why the number it produces is not something to act on unread.The acceptance is not independent of the fit it checks. The Gap is read on the subspace fGRC chose under the assumption of K clusters, against references drawn inside that same fixed subspace. On white noise at N=30 with c2=1 the fitted subspace puts its Gap maximum at K in 10 to 11 replications out of 12, against 0 to 2 for a random projection of the same basis, which is chance. On structureless 18-unit panels with c2=1, every replication accepted a candidate.
A maximum at an end of the evaluation grid is a property of the grid. On the 17-unit Basque panel it moves with the grid -- 1, 6, 8, 10, 12, 16 for grids 1..4 through 1..16 -- and the K that follows moves the donor pool from 15 units to 9 and the ATT from -0.365 to -1.212.
FGRCSelectionreports both:boundarynames the candidates whose curve peaked at an end of the grid, they are excluded from acceptance and from the relaxation, andselected_kisNonewhen that leaves nothing.one_secarries Tibshirani's one-standard-error reading, which is the conservative instrument: across 18 structureless panels it answered k=1 on all 54 candidate curves, and on the paper's planted design it answers 3. The evaluation grid must now extend past the largest candidate, and a panel too small for such a grid is refused instead of silently truncated -- on five units the old truncation reported two candidates as accepted on pure noise.Validation is against the planted design of the paper's Section 5 and against the null of that same design with the separation removed. Only this stage of Algorithm 1 is implemented; the smoothing lambda by GCV and the penalties by pseudo-F are taken from the caller.
-
FDID's reported standardised ATT carried a stray factor of
sqrt(post_periods). Li (2023) Proposition 2.1 gives the statistic assqrt(T2) * ATT / sqrt(Omega_1 + Omega_2), and this module's standard error issqrt(Omega_1 + Omega_2) / sqrt(T2), so the statistic isatt / seand the scaling was applied twice. On Li's own Hong Kong panelSATTread 22.65 where her MATLAB prints 5.49, a factor ofsqrt(17).Only that field moved. The confidence interval and the p-value are built from
att / sedirectly and already matched Li's released values, so a result carried a test statistic and a p-value that disagreed about the same fit: on a panel near the significance boundary,P-Value0.216 beside aSATTof 3.911, whose two-sided tail is 0.000092. Anything read offEffects.SATTon a borderline effect could have been the opposite conclusion from the p-value beside it.The suite had the correct statistic in three places and compared none of them to the reported one: the p-value, the confidence interval, and
benchmarks/cases/fdid_normality_mc.py, which rebuildsr.att / r.att_seby hand and never readsr.satt. The regression tests assert the identity from four directions, one of which -- that the p-value is the two-sided tail of the statistic printed beside it -- needs no reference at all. -
The GEOX engine property suite skips an engine whose optional dependency is absent instead of failing it. Registering
engine="mvbbsc"put an engine that needs numpyro into a registry thattests/test_geox_engine_properties.pyparametrizes over unconditionally, so in any environment installingrequirements.txtalone the eight mvbbsc cases raisedMlsynthEstimationError: MVBBSC requires NumPyroinstead of reporting the engine absent.The pull request gate did not see it.
build.ymlrunspip install numpyroexplicitly, so both PRs were green;coverage-badge.ymlandmutation.ymldo not, and the daily badge went red on main with exactly those eight failures.Enginenow carriesrequires, the optional imports an engine cannot fit without, and the suite reads it from the registry instead of naming engines, so a later engine inherits the behaviour. An engine module still imports its dependency insidefit_onceand never at module scope, which is what lets the registry be resolved without it.mutation.ymlinstalls numpyro too, and that one was the worse exposure: the bayes testsimportorskip, so a target whose tests all skip exits 0 and the harness reads the mutant as having survived. That reports confidence nobody measured, which is the one outcometools/mutation/run_mutants.pyis written to refuse.
-
test_scale_invariance_of_weightsno longer asserts against a constant the sampler's own spread can exceed. MVBBSC standardizes internally, so rescaling every series leaves the posterior weights alone -- exactly, in arithmetic. The sampler is where that stops being exact:(1000*y - mean(1000*y))/std(1000*y)is not bitwise(y - mean(y))/std(y), the two standardized panels differ by about 1e-14 in float64, and NUTS carries that into the draws. In single precision, NumPyro's default, the difference rounds away and the check comes back at 0.0; MTGP and BPSCS callnumpyro.enable_x64()at import and it is process-wide, so which of the two happens depends on what else the process imported.At the 80-draw chains the check used, the measured numbers say the 0.05 constant was never a bound. Across eight panels under x64 a rescale moved the posterior-mean weights by a median of 0.0211 and at most 0.0374, and refitting the same panel at another seed moved them by a median of 0.0213 and at most 0.0384 -- the same distribution, which is the invariance holding. A constant 1.4x above that floor passes on luck, and on one CI build it drew 0.0587 and failed.
The check now bounds the movement by the sampler's own re-run spread, measured in the same process on the same panel, with 0.05 kept as a floor since two seeds can land close by chance. That comparison is build-independent where a constant is not. The chains are also lengthened to 400 draws, which halves both quantities (0.0072 median movement against 0.0102 median spread) and costs no measurable time, since compilation dominates. A mutant that drops the scale divisor is killed, so the looser bound still catches the defect the check exists for.
-
MVBBSC no longer depends on the order its donors arrive in. Donor column order carries no information about the effect -- the pool is a set -- but NUTS walks a parameter vector, so permuting the simplex coordinates changes the trajectory and two runs at one seed disagree by however far it diverges. On the German reunification panel, permuting the 16 donors moved the posterior-mean weights by 7.2e-3 and the mean post-1990 ATT by 6.03, which is 0.29% of the estimate: an answer that changed because somebody sorted a dataframe.
run_mvbbscnow sorts the donor columns lexicographically on the pre-period before sampling and maps the draws back, so every permutation of one donor set presents the sampler with one input and the weights still come back against the columns the caller passed. The relation is exact, so the tests carry no tolerance.mvbbsc_germanystill passes on its existing pins (mean_att -2078 against -2080 +/- 500, pre_rmse 61.8 against 62.2 +/- 20).The tests are parametrized over
target_accept, and that is not incidental. It defaults to 0.8 and moves the ATT on this panel by 5.92, the same size as the 6.03 the donor order moved it, so a check at one setting is a check where two effects of equal magnitude can cancel. The pre-fix natural order at 0.9 and the post-fix canonical order at 0.8 agree to 0.1 by coincidence, which is enough to make a working fix look inert. Two mutants intools/mutation/targets.tomlpin both halves: dropping the canonicalisation, and returning the weights in the sampler's internal order so every weight is attributed to the wrong donor while the ATT stays correct.
-
engine="mvbbsc"on GEOX: the Bayesian synthetic control of Martinez and Vives-i-Bastida (2024) in the slot augsynth occupies, needing the[bayes]optional dependency. Nomination, backtest windows, effect injection, the power sweep, the MDE rule and the composite rank are untouched, so the engine supplies the fit and the null and nothing else. Two things differ from callingmlsynth.MVBBSCon the same panel, and both follow from what a scoring loop asks. The ATT interval carries the pre-period AR(1): a design's readout averages a post window, so under an iid shock its variance falls as sigma^2/h while under positive autocorrelation it falls more slowly. On Meta's GeoLift panel, every one of its 40 locations taken in turn as an untreated placebo, a nominal 90% interval on the iid shock covers 65% and this one covers 87.5%, against augsynth's conformal 89.7% at 1.7x the width. Donor order is handled inrun_mvbbsc(see Fixed), so the engine inherits that invariance instead of re-imposing it, and it is exact. Scale and location equivariance are not exact and hold at the engine's declaredfit_tolerance(see Changed). -
Engine.fit_tolerance: the relative precision at which an engine reproduces itself on one panel, declared on the engine and read by the metamorphic property suite.sdidandaugsynthsolve deterministic programs and keep the 1e-6 default;mvbbscsamples and declares 5e-2.The number exists because the suite's fixed 1e-6 was asserting a rounding accident. Rescaling or shifting a panel is exactly equivariant in arithmetic and the model's standardization is not bit-exact in floating point: the standardized arrays reaching the sampler differ in the last ulps, 1.3e-14 on a rescale and 6.7e-15 on a shift. NumPyro samples in single precision by default, where that difference rounds away and the relations held to 0.0 exactly. MTGP and BPSCS call
numpyro.enable_x64()at import and it is process-wide, so in a full-suite run the difference survives and NUTS carries it into the draws -- 4.3e-03 on a rescale, 2.1e-03 on a shift. The engine was green on its own file and red in the suite, and which it was depended on what had been imported first.5e-2 is measured. Over six generated panels a rescale moved the posterior weights by at most 5.7e-3 and a shift by 1.1e-2, while refitting the same panel at another seed moved them by 1.2e-2 -- the transformation costs no more than running the sampler again, which is the claim the relation is making, and 5e-2 is twice the worst of those.
test_a_transformation_moves_the_fit_no_more_than_refitting_doesasserts that sharp form directly, andtest_the_metamorphic_relations_hold_in_double_precisionre-runs the relations in a subprocess with x64 on, since a single process cannot test both modes. The donor-order relation is unaffected and stays at 1e-6.The global-state leak itself is untouched here: whether a NumPyro estimator runs in single or double precision still depends on whether MTGP or BPSCS was imported earlier in the process, and on one panel at one seed that moves MVBBSC's posterior-mean weights by 6.4e-3. That is a shared-helper fix and belongs on its own branch.
-
inference="bayes"in the GEOX vocabulary: the posterior predictive of the Bayesian engine. Available onmvbbscalone, and also the only null that engine admits, since substituting a placebo or conformal procedure would report a quantity the estimator did not produce -- the argument by whichsdidalready refuses conformal. The readout is a credible interval and a posterior tail probability, carryingmax_rhatandn_divergentso a design scored on an unconverged chain is visible as such. The other engines' defaults are unchanged: placebo forsdid, conformal foraugsynth. -
benchmarks/cases/geox_mvbbsc_equivalence.py: certification by equivalence. The engine and the estimator agree identically on West Germany -- the posterior-mean counterfactual, the ATT and the donor weights all to 0.0 maximum absolute difference, pinned without a tolerance because the two are one sampler call on one standardized panel and anything else would mean the wrapper introduced a transformation. Both sides are compared at float64: NumPyro samples in x32, so measuring the engine's upcast draws against the raw output reads as a 1.8e-08 disagreement that is precision and not computation, and the counterfactual hides it because a matrix product promotes implicitly where a mean over draws does not. Reversing the donor columns changes nothing the engine reports. The agreed ATT sits inside the bandmvbbsc_germanyalready pins and beside bsynth's -2075, so the equivalence is anchored to the external reference and not only to itself. -
conformal_horizononPPSCMConfig: a conformal band on each treated unit's CUMULATIVE effect, reported onPPSCMUnitFitascumulative_effect,cumulative_lower,cumulative_upperandcumulative_windows. PPSCM reported the cumulative effect as a point estimate with no interval calibrated for it; its per-unit bands carry only the CFPT out-of-sample term, and the in-sample bound mlsynth ships assumes unconstrained weights where PPSCM's live on a simplex. Calibration slides an origin across the pre-period and treats every unit as adopting there: the partially-pooled fit produces all of them in one solve, so a pass costs one solve per origin rather than one per unit per origin, and each unit's summed out-of-sample error is one conformity score for it. The half-width is the sharedconformal.cumulative_conformal_interval, so the order statistic keeps a single definition across estimators. The band is additional rather than a mode (inference_methodstill selects the bootstrap or jackknife behind the ATT) and is off unless the field is set. Too few non-overlapping windows for the requested level gives an infinite band rather than one that does not cover.
inference="conformal_cumulative"onVanillaSC: a prediction interval for the cumulative (total) treatment effect overconformal_horizonpost-periods, defaulting to the whole post-period. mlsynth already reported the cumulative effect as a point estimate, and the one existing "total" band rescaled a per-period interval by the horizon; this calibrates a band for the sum itself. The half-width is the split-conformal order statistic of summed out-of-sample errors, collected by refitting at sliding non-overlapping origins across the pre-period, so neither in-sample optimism nor an assumption about how period-to-period errors accumulate enters. The figure lands inres.inference.details(cumulative_effect,cumulative_lower,cumulative_upper,conformal_q,n_calibration_windows);ci_lower/ci_uppercarry the per-period equivalent only when the horizon spans the whole post-period, since a shorter window is not the ATT. Too few non-overlapping windows for the requested level warns and returns an infinite band rather than one that does not cover.mlsynth/utils/conformal/, collecting the conformal machinery into one package (followingutils/bilevel/):quantile.split_conformal_quantile(moved fromutils/inferutils.py, which re-exports it, so existing imports are unaffected),scores.rolling_origin_block_sums,cumulative.cumulative_conformal_intervalandcumulative_conformal_from_refit, andstructure.CumulativeConformalBand. The pure combiner takes precomputed scores, so an estimator whose refit produces several treated units at once can build its own scores in a single pass and reuse the same calibration.
- A row whose unit or time key is missing is refused, at three points:
BaseEstimatorConfig,datautils.balance, anddatautils._fast_pivot.balancecounted withnuniqueandgroupby, both of which skip missing values, so a panel whose blanks were symmetric across units satisfied every check it made;_wide_pivotthen pivoted the same frame and pandas kept the blank as a level. The two disagreed about what a key is, and the consequence moved the estimand: on a 3-unit, 4-period panel treated from period 3, rows with a blank time key produced a fifth period sorted to the front and tookpre_periodsfrom 2 to 3, with nothing raised. A blank unit key produced a donor column namednan.BaseMAREXConfighas rejected these all along but is referenced 14 times againstBaseEstimatorConfig's 150, and 38 modules reach ingestion with neither a config norbalancein the call path, so the refusal lives at all three. Scoped to the keys: a blank outcome still constructs and still ingests, which is where this stays narrower than the MAREX contract. Breaking for panels that previously passed with blank keys — those results were computed against a shifted pre/post window.
datautils.balanceanswers from the panel's key codes instead of two hash passes over the frame. Every estimator validates withbalanceand then reads withdataprep, and between them the(unit, time)key structure was established three times by three different methods:duplicatedon the pair,groupby(unit)[time].nunique(), and thefactorize+bincountinside_fast_pivot— which runs twice, once for the outcome and once for the treatment. The third already answers the first two, andbalancewas 35 to 47 percent of ingestion cost, more than the pivot it precedes. Same three checks, same order, same messages: the verdict is pinned differentially against a verbatim copy of the previous implementation over a fixture corpus and over generated panels damaged in composable ways. Two details decide correctness — a missing key factorizes to-1and is left out of the levels, solen(levels)equals thenunique()it replaces and a blank-keyed row is excluded exactly asgroupbyexcluded it, and the cell index shifts the codes so-1cannot collide with a real pair; and the duplicate check counts cells only where the grid is within twice the row count, the bound_fast_pivotalready applies, because an unbalanced panel is precisely where the grid is not — 4000 singleton units would otherwise ask for 128 MB to report an error. Measured: 3.9x onbalanceat 840 rows, 2.8x at 500k, 25–29 percent of total ingestion, and a full SDID fit 1.05–1.22x faster with its ATT unchanged at0.0e+00. One verdict deliberately changes, and it removes a version-dependence:groupby'sobserveddefault flipped fromFalsetoTruein pandas 3.0, so a unit column typed as a Categorical carrying a level no row uses was rejected as unbalanced below 3.0 and accepted from 3.0 -- different answers for the same frame across the supported range.factorizereports only levels that occur, so the answer is now the same everywhere, and it is the right one: a category no row uses is an annotation on the dtype, not a unit of the panel.
utils/bilevel/minnorm.py::ridged_gram_reduction_is_safe: the Gram-reduction guard for a design the caller is about to augment withsqrt(ridge) * I, answered without building or factorising that matrix wherever the ridge settles it. The augmented Gram isX'X + ridge I, solambda_min >= ridgeandlambda_max <= ||X||_F^2 + ridge, givingsv_min / sv_max >= sqrt(ridge / (||X||_F^2 + ridge))from one Frobenius norm. The bound is one-sided in the safe direction -- clearing it proves the answer isTrue, failing to clear it proves nothing and the spectrum runs -- so decisions are identical to askinggram_reduction_is_safeabout the augmented matrix, by construction rather than by tuning.
- SDID's placebo loop asks the Gram-reduction guard through
ridged_gram_reduction_is_safe. A defaultvce="placebo"fit posed 1000 simplex programs and asked the guard about every one, each answer a full singular spectrum, and on a 101x120 panel all 1000 came backTruedecided by a ratio four to seven orders of magnitude clear of the 1e-8 tolerance. Nothing said a guard must cost less than the solve it protects, so it had grown past it: 4.25 s of a 16.6 s three-fit profile, against the batched solver's own 4.33 s. The unit-weight program carries a ridge and is now certified without a factorisation; 1000 SVDs become 500 and the fit goes 4.28 s to 3.64 s with the ATT and its standard error unchanged at0.0e+00. The time-weight program carries no ridge, so the bound is vacuous there and it still pays -- asserted intest_sdid_placebo_guard_cost.py::TestReachOfTheFixso the limit of the fix is visible rather than assumed.
- The FISTA warm start that seeds the exact simplex active set is computed in
utils/bilevel/active_set.py::solve_simplex_qpinstead of inridge_augment.simplex_qp. Being accelerated was a property of one entry point, and of the thirteen call sites in the library twelve never supplied a warm start: MEDSC, SCD, COMPSC, StackedSC, mlSC, the proximal over-identified weights, the twominnormfallbacks and SDID's two simplex programs all started from the uniform point. From there the active set sheds one donor per pivot, so its work tracked the donor pool and not the support it ends on -- 0.62 to 0.87 pivots per donor from J = 20 to J = 320, against a support that grows 7 to 43. Seeded, the same problems take 0 or 1 pivot. That is why SDID took 117 inner least-squares solves on a 101x120 panel whereVanillaSC, which entered through the accelerated door, took 31. Speed only: the exact active set still determines the weights, and on SDID the two paths agree to 0.0e+00. Passaccelerate=Falsefor the cold path. fista_warm_startstops once the seed's support has held forSUPPORT_PATIENCEiterations (100, sampled everySUPPORT_CHECK_EVERY), read at theSUPPORT_TOLthe active set itself pins variables at. Itstol=1e-7rule tests how far the iterate moved, which is the wrong question for a seed whose job is to name the support: on the two SDID programs of a 101x120 panel the support was final by iteration ~150 and the loop ran to 400. The stop now fires at 261 and 231 and takes those two programs from 50.0 ms to 36.7 ms. The counter arms only after the first coordinate is pinned, since FISTA starts at the uniform point where the support is the whole pool and has not moved because it has not started. Sizing: one saved iteration is worth ~44 us and one pivot the seed fails to save costs about 27 of them, so the default is set where the stop fires only on a support that has durably settled -- across 27 designs it costs zero pivots against the full run. Passsupport_patience=Nonefor the old behaviour.
VanillaSC's per-fold refit closure is defined once and shared byinference="ttest"andinference="conformal_cumulative", and takes period indices rather than sliced arrays so a covariate-aware refit can subset its covariates by the same periods.
-
mode="two_way_global_annealed"and its fiveutils/syndes_helpers/relaxed_*.pymodules (932 lines), therelaxed_max_iter/relaxed_decayfields, theRelaxedSolverResultscontainer,permutation_test_relaxed_globalandplot_relaxed_design. The annealed relaxation existed to dodge the two-way MIP's cost on problems SCIP could not finish; the treated-set search finishes those directly and exactly, so the slower approximate path has no remaining use. Breaking: configurations naming that mode now raiseMlsynthConfigError. -
utils/syndes_helpers/accelerate.pyand theaccelerate,accel_min_tuples,accel_safety_marginandcertify_sdp_n_maxfields. The warm start and SDP objective cut only ever applied to the two-way branch-and-bound, which is no longer the default route, and the certificate that consumed the same lift now uses the closed-form Rayleigh bound._sdp_moment_bound_two_waygoes with them.utils/miqp_accel.pystays:solve_synthetic_designstill routes to it when a caller supplieswarm_start_Dorobjective_lower_bound, though nothing in the library reaches it by default any more. -
Donor-side restrictions under
mode="two_way_global":donor_exclusion,donor_region_colandexclude_bordering_donorsnow raise for that mode, anddonor_constraintsrefusesglobal_2waydirectly. A donor rule readsw[j] - q[j] <= 1 - D[i], which ties the control weights to which units are treated, so a design stops being scoreable from its treated set alone -- the one restriction the search cannot express.one_way_globalandper_unitkeep them unchanged. Every other restriction (forced, forbidden, cluster and adjacency conflicts, stratum quotas, size eligibility, costs with a budget) is a predicate on the treated set and is applied during the search. -
The GeoLift market-selection estimators (
GEOLIFT,MULTICELLGEOLIFT) and theirutils/geolift_helpers/package are removed from the public library. The two size/attribute eligibility primitives that SYNDES borrowed (eligible_by_size,unit_attribute_map) now live inmlsynth.utils.syndes_helpers.eligibility; SYNDES, LEXSCM, and MAREX are unaffected.compare_methods/ the design-comparison utility drop the"GEOLIFT"method and thefrom_geoliftadapter (SYNDES / LEXSCM / MAREX remain). The remaining design estimators cover market selection under a budget.
-
PDAConfig.lasso_criterionandPDAConfig.lasso_mbic_const: the L1 variant can now select its penalty by Shi & Huang's modified BIC,log(sigma^2) + H log(log N) log(T1)/T1 k, minimised overfsPDA's gridseq(0.01, 1, by = 0.01).lasso_criterion="mbic"reproduces theirlasso.BIC, which means the fit conventions move with the criterion: no intercept andglmnet's column scaling, since the penalty is chosen by scoring that fit.fit_lassogains astandardizeflag for it, and the prediction-interval bootstrap refits under the same conventions at the same fixed penalty. Checked againstglmnet4.1.8: the scaled path agrees to 5e-09, and on a 20-donor panel both implementations return a penalty of 0.32, the same single donor, and a coefficient of 0.51838043. The default is unchanged:lasso_criterion="cv"is the 5-fold cross-validated rule the estimator has always used, and the point estimate it returns is bit-identical to before. -
utils/syndes_helpers/enumeration.py: the exact two-way backend now builds candidate treated sets inside the structural restrictions instead of generating everyC(N, K)subset and testing each one. Forced units, forbidden units (including size-ineligible ones) and stratum quotas decide which candidates exist, anddesign_restrictionskeys each unit to one stratum, so the admissible designs are a product of per-group choices thatSearchSpace.sizecounts exactly without walking.candidate_limitis compared against that count, so an instance whose unrestrictedC(N, K)is past the limit is now solved exactly whenever the restrictions leave few enough designs -- where before it raisedMlsynthConfigError. Conflict pairs,costswith abudgetand the pool's no-good sets do not decompose over a stratum, so they remain tests on finished candidates and do not lower the count; an instance carrying only those is still refused past the limit. With no restrictions the walk reproducescombinations(range(N), K)term for term, including order, so no existing result moves. -
mlsynth.save_spec/mlsynth.load_spec: serialize an analysis specification to a portable JSON or YAML file and load it back into a ready-to-fit estimator. Because a configuration is plain, validated data, everything but theDataFrame(and any runtime arrays such as adjacency or spatial matrices) can be written to a text record, version-controlled, and reloaded -- separating the durable record of what analysis was run from the data it ran on. The DataFrame and matrix payloads are dropped on save and re-attached at load time (load_spec(path, df=..., adjacency=...)). AddsPyYAMLas a dependency. Covered bytests/test_spec.py, including a parametrized check thatload_specresolves and behaves gracefully for every estimator the package ships. -
SYNDESConfig.backendselects howmode="two_way_global"is solved (see Changed for the default it now takes)."exact"searches treated sets directly: naming the set removes theq = w * Dcoupling, so the outer problem is a choice of treated set and the inner problem is convex. The search scores every candidate with the closed form inutils/syndes_helpers/gram, settles the candidates whose sign conditions are slack, prunes the rest against the incumbent, and reaches the projected- gradient solver inutils/syndes_helpers/partitiononly for the survivors -- nought or one design out of thousands on the panels used to develop it. A candidate costs one row of a matrix product instead of a branch-and-bound node, andTleaves the per-candidate cost once the Gram matrix is formed. Above a candidate count the search falls back to a swap search reported against the Rayleigh bound, and says so instead of claiming a certificate.Comparing the two backends needs one caveat:
gap_limitdefaults to0.05, so the MIP path may return a design 5 percent above the optimum, and does on some panels. The two agree on the treated set once the MIP is asked to prove optimality (gap_limit=0.0); otherwise the exact backend's design is the one that is at least as good.backend="exact"is rejected forone_way_globalandper_unit, where the reformulation does not apply. Every other restriction is a predicate on the treated set and is applied during the search.select_by_holdouttakes apool_fnso holdout and IC selection reach the new backend. Covered bytests/test_syndes_exact.py,tests/test_syndes_partition.py,tests/test_syndes_backend.py,tests/test_syndes_exact_properties.py, and nine semantic mutants intools/mutation/targets.toml.
-
pda_helpers/inference.hac_lrvdivided the lag-lautocovariance by its own product countn - l; every standard HAC estimator, R'sacf(type = "covariance")among them, divides byn. The lagged terms were inflated byn / (n - l), which cost the autocovariance sequence its positive semi-definiteness and broke the claim thatfsandhcw'slrvar_lagbranch reproduces Shi & Huang's releasedfsPDApackage. On their dense simulation atT2 = 50, h = 1the forward-selection t-statistic was wrong in its third decimal; under the correct convention it agrees with theirFS()to 2e-11. The error grows with the truncation lag. Effects, weights and selected donor sets are untouched -- only standard errors, t-statistics, p-values and confidence intervals move, and only where a fixed lag is in play:fsandhcwat their defaultlrvar_lag=Noneuse the prewhitened Newey-West path and are unaffected. Thel2HAC t-statistic moves by about 1% (Hong Kong 7.799 -> 7.825, PPI 4.482 -> 4.547), inside the tolerances those benchmark cases already carry. -
utils/miqp_accel.solve_warm_cutwrote each warm-start bit to the wrong SCIP variable on problems above roughly eleven units. cvxpy returns the boolean columns as aset, and the accelerator iterated it directly, sowarm_bits[j]was paired with whichever column the hash table happened to yield, not with entryjof the boolean variable. The two orders agree for short index runs, which is why small problems behaved correctly and larger ones did not: SCIP was started from a design the caller never named. At N=200, K=6 under a 60 s limit the start left the solver worse off than no start at all (objective 0.2151 against 0.2070); it now returns the started design, 0.1812. The positions are read in the variable's own order, which for one contiguous boolean block is ascending column. No shipped estimator was affected: nothing in the library passeswarm_start_Dorobjective_lower_boundsince the SYNDES accelerator was removed, so this was a latent defect on a reachable but unused path. -
AccelInfo.warm_appliedis set from whataddSolreturned instead of from having called it, and its documentation no longer says the start was "accepted". A stored partial start is one SCIP will try to complete over the continuous variables, not one it has already adopted.
-
The fixed-penalty LASSO fit in
pda_helpers/lasso/estimation.pyruns totol = 1e-12instead of scikit-learn's default1e-4, which is what the modified-BIC path needs to reachglmnet's coefficients. The only existing caller is the LASSO prediction-interval bootstrap; on Hong Kong its counterfactual moves by 6e-06. TheLassoCVpoint estimate is unaffected, which leaves that bootstrap refitting the cross-validated fit's problem more exactly thanLassoCVsolved it -- a gap of the same 6e-06, pinned intest_pda_lasso_mbic.pyand left alone, since closing it would move every cross-validated LASSO result in the library. -
SYNDESConfig.backenddefaults to"exact", somode="two_way_global"is solved by searching treated sets instead of by branch-and-bound."mip"remains selectable andone_way_global/per_unitare untouched -- the default resolves to"mip"for them, since one-way pins the treated weights and per-unit carries an(N, N)weight matrix, so neither reduces to a search over treated sets. Asking forbackend="exact"there is an error.This changes what a two-way fit returns on some panels, and in one direction:
gap_limitdefaults to0.05, so the MIP could stop at a design 5 percent above the optimum, and on the panels intests/test_syndes_backend.pyit does. The search has no early exit below its candidate limit, so where the two differ the new default is the better design. Setbackend="mip"to reproduce a prior result exactly. -
Version bumped to 2.0.0 for the removed public API.
-
The SYNDES two-way optimality certificate (
certify=Truewithmode="two_way_global") now reports a closed-form bound on the Gram matrix instead of the SDP / moment lift, andresult.certificate.methodreads"rayleigh"in place of"sdp_moment". Naming the treated set removes theq = w * Dcoupling, leaving a convex program inG = Y'Y / T; the two weight normalisations are then a pair of linear equalities, and dropping the sign conditions giveslb(S) = 4 alpha / (sigma' R sigma)withR = alpha H - p p',H = (G + lam I)^-1,p = H1,alpha = 1'H1. SinceR1 = 0, Rayleigh's inequality bounds every size-Kdesign at once. On the panels used to develop it the bound reached 88.8 / 90.6 / 92.0 percent of the true optimum atK = 3 / 5 / 7, against the lift's 83.2 / 84.9 / 86.2, in about 0.1 ms against 0.1--0.23 s. Two consequences for callers: the two-way certificate no longer consultscertify_sdp_n_max, since there is no size at which it needs to fall back to the loose continuous bound, and it no longer goes absent because a conic solve hit its iteration cap. It does reportlower_bound=NonewhenG + lam Iis near-singular, which happens aslamapproaches zero on a panel with fewer pre-periods than units; the note names the cause. Newmlsynth.utils.syndes_helpers.gram, covered bytests/test_syndes_gram.pyandtests/test_syndes_gram_properties.py. -
SDID solves its placebo draws' weights as one family. Placebo inference (Arkhangelsky et al. 2021, Algorithm 4) refits both weight programs once per draw and
Bdefaults to 500, which is where an SDID fit spends its time: 84 percent ofsdid_prop99's wall clock was inside the simplex solver. The draws differ only in which donors are in the design, what the target is, and how large the ridge is, and none of that needs its own factorisation -- centring is per column so it survives subsetting, and the ridge augmentation carries no target rows so with the weights summing to one it enters the Gram as+ ridge I.estimate_placebo_variancenow draws every assignment first, solves the family through the newmlsynth.utils.sdid_helpers.weights.solve_intercept_simplex_many, then replays; the draws are built in the order the old loop used them, so the RNG stream is untouched and the same controls are cast as pseudo-treated. On Prop 99 atB = 500a fit runs in 0.75s against 1.77s, with the ATT unchanged bit for bit and the placebo standard error moving in its eleventh significant figure.sdid_prop99,sdid_ddd_hpvandseq_sdid_mcall pass. Available to SDID because its weight designs are overdetermined, sogram_reduction_is_safepasses and the batched and one-at-a-time solvers return the same weights and not merely the same fit; it is checked per problem regardless. Pinned bytests/test_sdid_weights_batch.py. -
mlSC scores its penalty grid in one pass under
lambda_est="cross-validation". Folding a penalty into the design as asqrt(lambda sigma_y^2) Raugmentation adds rows carrying no target, so with the weights summing to one the augmented Gram is affine in the penalty,G(p) = (X - Y 1')'(X - Y 1') + p R'R, verified against the assembled design to 1e-9 across the grid. The two matrices are formed once, each grid point is a broadcast off them, and the batched active set certifies the whole grid together: on the Bottmer et al. panel (108 training periods, 90 disaggregate controls, 56 grid points) 0.27s against 4.49s, the same penalty selected, andmlsc_bottmer's agreement with the author'smlSC_estimatorunchanged atpath_a_cv_lambda_rel = 0.The reduction is guarded. Forming the Gram squares the design's condition number, which is free only where the design has full column rank -- and this grid runs the penalty to zero, where the augmentation is a
1e-8uniqueness ridge. On a rank-deficient training design that ridge is the only thing separating the columns and squaring puts it below what float64 resolves: on a 9-period, 12-disaggregate panel the Gram form then finished 225 percent above the optimum atlambda = 1e-8and selected a different penalty. The newmlsynth.utils.bilevel.minnorm.gram_reduction_is_safedecides this from the design before anything is solved, and a rank-deficient one keeps the one-penalty-at-a-time solve. It states both failure modes the reduction has -- the other being a design whose minimiser is a face and not a point, where both solvers are optimal but land in different places. Pinned bytests/test_mlsc_crossval_batch.py. -
VanillaSC solves its in-space placebo as one leave-one-out family when there are no covariates. The refits fit each column of the donor matrix from the others, so the family falls out of a single
Y0' Y0-- deleting a donor deletes a row and a column of it, and each target is itself a column -- andmlsynth.utils.bilevel.minnorm.solve_simplex_loo_exactassembles them with no product with the data per refit. The default no-covariate call is where this lands: a Proposition 99 fit is 0.007s with inference off and 0.205s with it on, 94 percent of that in the solver.38 donors x 19 pre 0.209s -> 0.025s 8.5x 48 donors x 89 pre 0.394s -> 0.038s 10.2x 119 donors x 30 pre 5.617s -> 0.137s 41.0xThe p-value is a rank statistic over these fits, so each member is verified with
simplex_optimum_is_uniqueand any whose minimiser is a face is re-solved with the single-problem active set the loop used -- a different exact solver has an equal claim there, and the published ranks came from that one. p-value, rank, RMSPE ratio and ATT are identical on every panel tried. Refits that are not a plain simplex fit keep the loop (covariates, ridge augmentation, the penalized backend), whichtests/test_vanillasc_placebo_batch.pyasserts by disabling the family solver and requiring the answer not to move. -
STACKEDSC solves each cohort's weight programs as one shared-design family. Every treated unit in a donor pool faces the same design and differs only in its own pre-treatment target, so with
c_j = A' b_jands_j = b_j' b_jthej-th Gram isA'A - c_j 1' - 1 c_j' + s_j 1 1': one Gram and one cross product carry the group, andmlsynth.utils.bilevel.minnorm.solve_simplex_shared_designruns it in lockstep. A donor predicate that binds gives one batch per distinct pool, down to a batch of one per unit. On a Wiltshire cohort of 89 units against 39 donors the outcome-only design goes 396ms -> 43ms, 9.3x. The covariate design does not batch (see below) and is unchanged. -
STACKEDSC's placebo layer solves each pool as a leave-one-out family. Casting every donor as treated in turn and refitting against the rest is one matrix's columns fitted against one another, which
solve_simplex_loo_exactassembles from a singleM' M. Underdonors-onlythat matrix is the cohort's design and the family is shared by the cohort; underpermutation-- the default, following the reference implementation -- the treated unit's column is appended, a donor to every placebo and a target to none. On the Walmart panel that is 22,074 programs in 21s where the loop takes 101s, 4.8x, with the RMSPE-ranked p-values bit-identical and every other reported statistic unchanged to 1e-11. -
mlsynth.utils.bilevel.minnorm.simplex_point_is_optimalcertifies a simplex weight vector against the design it claims to solve, from the KKT conditions onB'(Bw - A). The batched solvers now require it as well as uniqueness before standing in for the one-at-a-time solve. Uniqueness alone was not enough, and the gap is not academic: the Gram reduction squares the condition number, so a design merely awkward atcond(B) ~ 1e7-- covariates measured in different units -- gives a Gram at the edge of float64, and the batched active set then converges on that Gram to a point that does not solve the program it came from. On the covariate specification of the Wiltshire panel that is 62 of 76 members of a cohort, with a KKT residual of 7e-3 where the design-form solver leaves 6e-10; nothing computed from the Gram reveals it. Those members now fall back and the reported effects are unchanged to 1e-9. -
solve_simplex_loo_exactandsolve_simplex_shared_designtake afallbacksolver, since which one-at-a-time solve a batch stands in for is part of the contract. STACKEDSC calls the primal active set directly; VanillaSC's engine calls it through a wrapper that escalates to CVXPY when the active set reports failure on itself, and on a design pathological enough to trip that hatch the two disagree. Each call site now names its own. -
mlsynth.utils.bilevel.minnorm.simplex_optimum_is_uniquesettles, after solving, whether a simplex least-squares minimiser is the only one -- the question that decides whether the batched and one-at-a-time solvers can stand in for each other.gram_reduction_is_safeanswers it from the design's shape and is sound but far from tight: rank deficiency is only a precondition for a face, since the objective is flat along a direction only where that direction is also feasible at the solution, which needs a support large relative to the design's rank. Synthetic-control solutions are sparse, so the ordinary geometry -- more donors than pre-treatment periods -- usually has a unique minimiser after all. On the Proposition 99 placebo family all 38 solves do, at 37 donors against 19 pre-periods, and the shape test admits none of them; on STACKEDSC's Walmart data 85.5 percent of 2264 real solves do, against the blanket "not available" an earlier synthetic probe suggested. The predicate checks the design restricted to the weakly-active set, costs nothing measurable next to the solve, and is asserted against what the two solvers actually return intests/test_simplex_uniqueness.py. -
VanillaSC's
mscmtbackend (the default when covariates are supplied) solves its inner donor-weight program exactly, and for a whole outer-search generation at once. Because the weights sum to one the design matrix drops out of the inner objective:X1 - X0 w = R wforR = X1 1' - X0, so the V-weighted predictor loss is the quadratic formw' G(V) wwithG(V) = sum_p v_p r_p r_p', and the donor weights are the minimum-norm point in the convex hull of the donors' predictor discrepancies (Wolfe 1976), which an active set over the donors solves exactly and finitely.Gis linear inV, so thePrank-one pieces are formed once, a differential-evolution generation of Grams is one matrix product against them, and the active set certifies the generation in a handful of batched linear solves -- the data never enters the search loop. This replaces a per-candidate Lawson-Hanson NNLS whose sum-to-one constraint was a big-M penalty row, so the equality is now exact and the inner solution exactly scale-free inV, as the outer objective assumes. On the Abadie-Gardeazabal Basque specification the bilevel fit runs in 1.0s against 1.7s and the default call (in-space placebo, 17 refits) in 22s against 26s, with the MSCMT reference weights unchanged (benchmarks/cases/mscmt_basque.py). The new solver ismlsynth.utils.bilevel.minnorm(simplex_gram,solve_simplex_minnorm,solve_simplex_minnorm_batch), covered bytests/test_simplex_minnorm.pyandtests/test_simplex_minnorm_perf.py.solve_mscmtgains aninner_max_itercap and reportsmetadata["inner_unconverged"], warning once when an inner solve scored a candidate without certifying. MEDSC anddetermine_v, which share the_inner_weightsprimitive, inherit the exact solve; their pinned replications are unchanged.Each generation is solved cold. Seeding each candidate's active set from the previous generation's weights cuts the inner work by about a third, and it was measured and rejected: where the inner optimum is a face and not a point, the member returned would then depend on the search's history, and members of that face tie on predictor fit while differing on outcome fit, so the outer objective would stop being a function of
V. On the Lamba et al. tiger reserves that showed as a seed spread of 5e-2 ha on a 2825 ha effect, against 2e-6 ha cold (tests/test_lamba_tigers.py, which is the guard). -
The
mscmtouter search stops on a tolerance calibrated to the estimate, and that tolerance is reachable:VanillaSCConfiggainsmscmt_tol, and its default (andsolve_mscmt's) moves from1e-10to1e-6. scipy ends differential evolution when the population's spread in pre-fit MSPE falls belowatol + tol * |mean|, and withatol = 0that is purely relative. At1e-10, on the Abadie-Gardeazabal Basque specification whose mean energy is a pre-fit MSPE of 0.0043, the rule asked 195 candidate predictor weightings to agree to 4.3e-13 -- thirteen significant figures. Tracing the search shows the donor weights reach 1e-5 of their final position by generation 93 and move by 1e-8 over the 120 generations after that; many panels never reach the threshold at all and simply exhaustmaxiter. The new default stops around generation 100, leaving the weights and the ATT within 5e-6 of where the old one left them -- three orders finer than the four decimals the MSCMT replication compares to. On Basque the default call runs in 12.7s against 22.5s (and 26.5s before both changes), the bilevel fit in 0.57s against 1.04s. Agreement with the captured MSCMT R run is unchanged, marginally closer on three of its four pinned quantities. MASC and MEDSC sharesolve_mscmtand inherit the default; their replications are unchanged. Pinned bytests/test_mscmt_search_budget.py.
First stable release, published to PyPI (pip install mlsynth).
- Distribution publishes to PyPI via OIDC Trusted Publishing
(
.github/workflows/release.yml) on each GitHub Release -- no stored API token.python -m build+twine checkgate the artifacts first. - License metadata modernised to the SPDX form (
license = "MIT"+license-files = ["LICENSE.md"]), dropping the deprecatedLicense :: OSI Approved :: MIT Licenseclassifier; build backend bumped tosetuptools>=77. - The supported Python range (3.10-3.13) is now exercised in CI: the full suite
runs on 3.10, 3.12 and 3.13 (
pyversionsmatrix) plus 3.11 (build), so therequires-pythonfloor and the Python classifiers are test-backed, not asserted. Development status promoted to Production/Stable. - MSCMT no longer depends on scipy's
nnlsbeing a working release: the inner non-negative least squares selects scipy's compiled solver where it is the fixed, fast version (>= 1.15) and an in-house pure-NumPy Lawson-Hanson solver otherwise (e.g. the regressednnlsin scipy 1.13). Same optimum either way.
- PDA gains the original HCW best-subset method (
method="hcw"). Hsiao-Ching-Wan (2012): the treated unit's counterfactual is unrestricted OLS on the AICc/AIC/BIC best subset of controls. The combinatorial search is exact and fast -- a Furnival-Wilson sweep-operator branch-and-bound, a Bertsimas-King-Mazumder discrete first-order warm start, and a node budget that returns the incumbent with a certified optimality gap instead of refusing large pools -- with an optional SCIP mixed-integer backend (hcw_backend="scip", behind the newscipextra) for exact certification past the branch-and-bound's reach. Jiang et al. (2025) prediction intervals carry over. 100% covered; reproduces HCW Table XVI value-for-value, cross-validated against thepampeR package. - MAREX geographic design restrictions. MAREX gains the SYNDES/GEOLIFT
restriction vocabulary, on top of what it already had natively (region
clustering,
m_min/m_maxstratum quotas, cost/budget, same-region donors):to_be_treated/not_to_be_treated,adjacency+spillover_threshold(no two treated markets border each other),size_col+min/max_size, andexclude_bordering_donors(drop a treated market's neighbours from its within-cluster control pool). Enforced as constraints on the MIP'sz/v, reusing the shared estimator-agnosticDesignRestrictions/build_restrictions(newmarex_helpers/restrictions.apply_restrictions_marexfor the applier). MIQP-only (rejected withrelaxed=True); infeasible combinations raise a translatedMlsynthEstimationError. Docs gallery on real DMA geography.
- MAREX routes unit/time identity through
IndexSet+geoex_dataprep.prepare_marex_panelnow ingests via the canonicalgeoex_dataprep(which enforces a strongly balanced panel) and carriesunit_index/time_indexIndexSets as the single source of truth, threaded through the optimizer and orchestrator instead of being re-derived from the frame. Unit order is preserved, so numerics are unchanged; the only behavioural change is that an unbalanced panel now raises a translatedMlsynthDataError.
- SYNDES no longer leaks an
IndexErrorwhen restrictions make the candidate pool empty. Over-constrainedtop_K > 1designs (in-sample, holdout, or ic selection) whose every MIP solve is infeasible now raise a translatedMlsynthEstimationErrornaming the restrictions, instead of a bare "list index out of range". Surfaced while building the docs gallery.
- GEOLIFT docs gallery consolidating every geographic design constraint
(cardinality, force in/out, spillover non-interference via
cluster_col/adjacencywith the matching donor spillover exclusion, coveragemin/max_per_stratum, size bands, budget planning, and the full shortlist) into one unified showcase on the bundled real US DMA contiguity map + Census divisions, with a grouped linear factor sales model -- mirroring the SYNDES gallery so the two design estimators document the same surface in parallel. - SYNDES docs gallery showcasing every design-customisation knob
(cardinality, force in/out, no-two-treated conflict, stratum quotas, size
bands, region-matched and non-bordering donor pools, per-unit multi-region
designs, and the restriction-aware
top_Kmenu) as runnable MWEs on the bundled real US DMA geography + Census regions, with a region-grouped linear factor sales model. - SYNDES donor-side restrictions (region-matched / non-bordering donors).
Beyond constraining who is treated, SYNDES can now constrain who may serve
as a treated unit's donor. The primitive is a donor-exclusion relation
B[i,j]("ifiis treated,jmay not be its donor"), enforced by coupling the assignmentDto the mode's control weights, so it works in every mode (one-way globalc[j] ≤ 1−D[i], two-way globalw[j]−q[j] ≤ 1−D[i], per-unitw[i,j] = 0). Filled bydonor_region_col(a donor must share the treated unit's region),exclude_bordering_donors(drop a treated unit's spillover neighbours from its donor pool — the Vives-i-Bastida exclusion restriction, reusing the conflict graph), or an explicitdonor_exclusionmatrix (escape hatch), combined by union. In the global modes a region rule forces the treated set into one region;per_unitsupports a multi-region design (each treated unit draws its own same-region donors). Validated on the bundled DMA borders + CDC Census regions. Helpersbuild_restrictions/donor_constraintsinutils/syndes_helpers/restrictions.py. - SYNDES design restrictions (geography / clustering / size / forcing).
SYNDES now accepts the same restriction vocabulary as GEOLIFT and LEXSCM,
enforced exactly as linear constraints on the MIP assignment vector
D:to_be_treated(D_i = 1) /not_to_be_treated(D_i = 0, stays a donor);cluster_coland/oradjacency+spillover_threshold→ no two interfering units both treated (D_i + D_j ≤ 1, via the shared conflict-graph helper LEXSCM uses);stratum_col+min_per_stratum/max_per_stratum→ coverage quotas;size_col+min_size/max_size→ a treated-unit size band. Restrictions compose withcosts/budgetand flow through every selection rule (in-sample, holdout, ic). Not supported with the annealed mode or anarmcolumn. Over-constrained designs return translated errors, never a leaked solver status: config-detectable cases (unknown/overlapping forced units, forcing more thanK, too few treatable units) raiseMlsynthConfigError, and a solve-time infeasibility (e.g. asking for more mutually non-adjacent treated markets than the conflict graph allows) raisesMlsynthEstimationErrorwith a message naming the restrictions as the cause. Validated against the bundled real DMA contiguity matrix (basedata/markets/), restricting to Florida + Georgia. New helper moduleutils/syndes_helpers/restrictions.py(build_restrictions,apply_restrictions,DesignRestrictions). - SYNDES information-criterion (IC) design selection. A new
selectionconfig field unifies the design-selection rule into{"in_sample", "holdout", "ic"}(defaultNoneinfers"holdout"whenholdout_fracis set, else"in_sample", so existing configs are unchanged).selection="ic"ranks thetop_Kpool (solved on the whole pre-period — no data split) by an information criterionIC = SSR_pre + 2·sigma^2·df, withdf = active control donors − 1(Pouliot-Xie-Liu'sdf = |A| − 1for the unpenalised SCM) and a Mallows-Cp noise estimate, penalising designs that buy fit by activating more donors. Preferable to holdout when the pre-period is short. Eachresults.poolentry gainsicanddf; requirestop_K >= 2and a MIP mode. New helper moduleutils/syndes_helpers/infocriterion.py(design_df,select_by_ic). Incompare_methods, passsyndes_options={"selection": "ic"}to use it (overrides the default holdout). - SYNDES holdout (train/validate) design selection. A new optional
holdout_fracconfig field switches SYNDES from in-sample MIP selection to out-of-sample selection: thetop_Kcandidate pool is learned on the leading1 - holdout_fracof the pre-period and the winning design is the one whose held-out contrast error on the trailingholdout_fracis smallest (e.g.0.3for a 70/30 split) — a guard against overfitting transient pre-period co-movement. The returnedresults.poolis ranked by OOS error and each entry carries anoos_rmse. Requirestop_K >= 2and a MIP mode; power and inference are unchanged.holdout_frac=None(default) preserves the Doudchenko et al. (2021) in-sample behaviour exactly.compare_methodsnow defaults to holdout selection for SYNDES (syndes_holdout_frac=0.3), exposes anoos_rmsecolumn, and ranks the SYNDES rows by it; passsyndes_holdout_frac=Noneto revert. New helper moduleutils/syndes_helpers/holdout.py(split_pre,oos_contrast_rmse,select_by_holdout).
- SpSyDiD migrated onto the two-family result contract (final estimator of
the 9-estimator migration).
SpSyDiD.fit()now returnsSpSyDiDResultsas a frozen pydanticEffectResult. SpSyDiD is a spillover decomposition, so the standardized surface describes the direct effect:att(= the WLStau),counterfactual,gap,pre_rmseresolve via the inherited accessors against the directly-treated group's observed mean vs the pure-control SDID synthetic (the same reconstruction the plotter draws). The indirect (aite) and total (ate) effects — which have no single counterfactual path — are kept as typed fields and mirrored intoeffects.additional_effects. The pure-control SDID unit weights live in the standardizedweightsslot (time weights insummary_stats);inferenceisNone. Breaking surface change: the flatattfield is now an inherited accessor (thetau/tau_saliases still resolve), and the standaloneweightsfield is replaced by the standardized slot.inputs/aite/ate/unit_weights/time_weights/zeta/metadataremain typed fields. Plotting routes throughresult.plot(). Conformance is pinned intest_spsydid.py::test_two_family_result_contract(SpSyDiD needs a spatial matrix, so it can't join the single-df loop intest_result_contract.py).docs/spsydid.rstnotation rewritten to theagents_docs.mdcanon (calligraphic sets, bold spatial matrix\mathbf{W}, hatted estimates,\coloneqq, theT_0time split). - TASC migrated onto the two-family result contract.
TASC.fit()now returnsTASCResultsas a frozen pydanticEffectResultwith the standardized sub-models built from the observed target vs the smoother-based counterfactual;att/counterfactual/gap/pre_rmseresolve via the inherited accessors. TASC is a state-space / EM estimator with no donor weights, so theweightsslot records the method, not per-donor weights. Breaking surface change: the raw inference object (counterfactual- per-period posterior bands:
.counterfactual/.ci_lower/.ci_upper/.posterior_variance/.alpha) moved fromres.inferencetores.inference_detail; theinferenceslot now holds the standardizedInferenceResults(with the raw object in.details). The flatatt/pre_rmsefields are now inherited accessors;design/inference_detailremain typed fields. Mutating the frozen result raises pydanticValidationError. TASC plots viaresult.plot()and is pinned intests/test_result_contract.py.
- per-period posterior bands:
- SparseSC migrated onto the two-family result contract.
SparseSC.fit()now returnsSparseSCResultsas a frozen pydanticEffectResultwith the standardized sub-models built from the treated series viabuild_effect_submodels;att/counterfactual/gap/att_ci/pre_rmse/donor_weightsresolve via the inherited accessors, and the donor weights live in theweightsslot (predictor weights insummary_stats). Breaking surface change: the raw placebo/conformal inference object moved fromres.inferencetores.inference_detail(still.method/.p_value/.placebo_atts/.pointwise_*/ ...); theinferenceslot now holds the standardizedInferenceResultsbuilt from it (sores.att_ciresolves), and isNonewhenmethod="none". The flatatt/pre_rmse/donor_weightsfields are now inherited accessors;design/predictor_weights/inference_detailremain typed fields. Mutating the frozen result raises pydanticValidationError. SparseSC plots viaresult.plot()and is pinned intests/test_result_contract.py. - MLSC migrated onto the two-family result contract.
MLSC.fit()now returnsMLSCResultsas a frozen pydanticEffectResultwith the standardized sub-models built from the aggregate treated series (observed = counterfactual + gap,T0the adoption reference);att/counterfactual/gap/pre_rmse/donor_weightsresolve via the inherited accessors, and the disaggregate donor weights live in theweightsslot (aggregate_donor_weightsinsummary_stats). mlSC has no statistical inference, so theinferenceslot isNone. Breaking surface change: the oldres.inferencefield (which carried the fitted paths, not statistical inference — it clashed with the contract'sinferenceslot) is renamed tores.paths(still.counterfactual/.gap; the same series are exposed flat asres.counterfactual/res.gap). The flatatt/pre_rmse/donor_weightsfields are now inherited accessors.designandaggregate_donor_weightsremain typed fields. Plotting routes throughresult.plot()(thePlotConfigis built from MLSCConfig's legacy color fields, sinceMLSCConfigis a plainBaseModel). Conformance is pinned intest_mlsc.py::test_two_family_result_contract(MLSC's two-level panel can't join the single-df loop intest_result_contract.py). - MCNNM migrated onto the two-family result contract.
MCNNM.fit()now returnsMCNNMResultsas a frozen pydanticEffectResultwith the standardized sub-models built from the cross-treated-unit observed / imputed paths (T0 the common adoption reference);att/counterfactual/gap/att_ci/pre_rmseresolve via the inherited accessors, and the implied (non-unique) donor weights stay in theweightsslot. Breaking surface change (matrix-completion convention):res.counterfactualis now the 1-D treated counterfactual path (was the full(N, T)fitted matrix); the matrix moved tores.counterfactual_matrix, and the per-celleffectsmatrix tores.effects_matrix(theeffectsslot now holdsEffectsResults). The raw jackknife object moved fromres.inferencetores.inference_jackknife; theinferenceslot now holds the standardizedInferenceResults(sores.att_ciresolves). The staggered-adoption extras (cohort_att,event_study) and the factor diagnostics (L,gamma,delta,unit_factors,time_factors,singular_values,rank) remain typed fields. Mutating the frozen result raises pydanticValidationError. MCNNM plots viaresult.plot()and is pinned intests/test_result_contract.py. - MSQRT migrated onto the two-family result contract.
MSQRT.fit()now returnsMSQRTResultsas a frozen pydanticEffectResultwith the standardized sub-models built from the cross-treated-unit observed / synthetic paths;att/counterfactual/gap/att_ci/pre_rmseresolve via the inherited accessors, and the per-treated-unit PCR donor weights stay in theweightsslot. Breaking surface changes:res.counterfactual/res.gapare now the 1-D treated paths; the full(T, m)synthetic / gap matrices moved tores.counterfactual_matrix/res.gap_matrix. The raw SCPI prediction-interval object moved fromres.inferencetores.inference_intervals; theinferenceslot now holds the standardizedInferenceResults(sores.att_ciresolves). Mutating the frozen result raises pydanticValidationError. MSQRT plots viaresult.plot()and is pinned intests/test_result_contract.py. - SNN migrated onto the two-family result contract.
SNN.fit()now returnsSNNResultsas a frozen pydanticEffectResultwith the standardized sub-models built from the cross-treated-unit observed / imputed paths;att/counterfactual/gap/att_ci/pre_rmseresolve via the inherited accessors, and the PCR donor weights stay in theweightsslot. Breaking surface change (matrix-completion convention):res.counterfactualis now the 1-D treated counterfactual path (was the full(N, T)imputed matrix); the matrix moved tores.counterfactual_matrix, and the per-celleffectsmatrix tores.effects_matrix(theeffectsslot now holdsEffectsResults). The raw jackknife object moved fromres.inferencetores.inference_jackknife; theinferenceslot now holds the standardizedInferenceResults(sores.att_ciresolves). Mutating the frozen result raises pydanticValidationError. SNN plots viaresult.plot()and is pinned intests/test_result_contract.py. - RMSI migrated onto the two-family result contract.
RMSI.fit()now returnsRMSIResultsas a frozen pydanticEffectResultwith the standardized sub-models populated from the treated aggregate paths (treated_mean/synthetic_mean);att/counterfactual/gap/pre_rmseresolve via the inherited accessors. Breaking surface change (matrix-completion convention):res.counterfactualis now the 1-D treated counterfactual path (was the full(N, T)imputed matrix); the matrix moved tores.counterfactual_matrix, and the per-celleffectsmatrix moved tores.effects_matrix(theeffectsslot now holds the standardizedEffectsResults). RMSI is a matrix-completion method with no donor weights, soweightsrecords the method/rank. Mutating the frozen result raises pydanticValidationError. RMSI plots viaresult.plot()and is pinned intests/test_result_contract.py. - SPOTSYNTH migrated onto the two-family result contract.
SPOTSYNTH.fit()now returnsSpotSynthResultsas a frozen pydanticEffectResult: it populates the standardized sub-models (effects,time_series,weights,inference,fit_diagnostics,method_details) and exposes the flat accessors (att,att_ci,counterfactual,gap,donor_weights,pre_rmse). All previously public attributes still resolve, andatt_cinow reads frominference. One rename: the result's formerinferencefield (the"bayes"/"frequentist"label) is nowinference_method, becauseinferenceis the standardizedInferenceResultsslot; the config fieldSPOTSYNTHConfig.inferenceis unchanged. Mutating the frozen result now raises pydanticValidationError(notdataclasses.FrozenInstanceError). SPOTSYNTH plots via the standardizedresult.plot()and is pinned intests/test_result_contract.py.
- Standardized plotting foundation. A nested
PlotConfigonBaseEstimatorConfig(plot=...) centralizes plot cosmetics — observed/ counterfactual color, linewidth, linestyle; intervention reference line; axis-label and title overrides; a user-suppliabletheme— with sensible defaults and full back-compat (legacytreated_color/counterfactual_color/display_graphs/savefold in viaconfig.resolved_plot()). The sharedutils.plotting.Plottergainsgapandevent_studyarchetypes alongsideobserved_vs_counterfactual, all config-driven and rendered in the house style.EffectResult.plot(kind=...)is the single entry point, driven by the standardizedtime_seriessub-model (+intervention_time) and thePlotConfigcaptured at fit time. FDID is migrated as the reference; the event-study archetype is validated on SDID output. - FDID validation (Path A + B), officially complete. New
benchmarks/cases/fdid_hongkong.pyreproduces Li (2024)'s public Hong Kong GDP companion replication cell by cell (FDID ATT 0.0254 / 53.84% / pre-R² 0.843 / 9 of 24 controls; DID 0.0317 / 77.62% / 0.505), guarded in CI bytests/test_fdid_replication.py; the Table 5 simulation (fdid_table5.py) remains the Path B check. Replication page updated to document both. - Vectorized metric primitives.
utils/effectutils.py(treatment effects) andutils/fitutils.py(goodness-of-fit / loss) split the formereffects.calculateblob into bite-sized pure functions;effects.calculateand the newresults_helpers.build_effect_submodelscompose them so every estimator computes ATT/%ATT/gap/RMSE/R² from one consistent source. - Two-family result contract. A common
MlsynthResultbase with two faces:EffectResult(alias ofBaseEstimatorResults, the observational report) andDesignResult(the research design, whosereportis anEffectResult). Exposed via the newmlsynth/results.py. Flat convenience accessors (att,att_ci,counterfactual,gap,donor_weights,pre_rmse) onBaseEstimatorResults. mlsynth/tests/test_result_contract.pypinning the contract on the reference estimators.agents/agents_results.md: the result-object contract and per-estimator migration Definition of Done.
WeightsResultsis now the single weights container library-wide, exposingdonor_weights/time_weights/unit_weights(wasdonor_weightsonly). Purely additive.- VanillaSC, FDID, TSSC migrated onto the contract:
FDID.fit()andTSSC.fit()now return frozen PydanticEffectResultsubclasses (were frozen dataclasses). All previously public attributes and methods are preserved; the standardized sub-models (effects,time_series,weights,inference,fit_diagnostics,method_details) are now populated, sores.effects.att,res.weights.donor_weights, etc. read uniformly across the three.VanillaSC.fit()is unchanged in shape (already returnedBaseEstimatorResults); it gains the flat accessors via the base.
- Per-estimator configs relocated next to their helpers:
VanillaSCConfig,FDIDConfig,TSSCConfignow live inmlsynth/utils/<name>_helpers/config.py. Re-exported frommlsynth.config_modelsvia a lazy__getattr__, so existing imports (from mlsynth.config_models import FDIDConfig) keep working unchanged.
- No public estimator API removed. Config imports from
mlsynth.config_modelsstill resolve to the same classes. The only intentional surface change: assigning to a frozen FDID/TSSC result now raises pydantic'sValidationErrorinstead of the dataclassFrozenInstanceError.