@@ -648,3 +648,122 @@ def test_local_bootstrap_nan_with_survey(self, trop_survey_data, survey_design_w
648648 )
649649 assert np .isfinite (result .att )
650650 assert np .isfinite (result .se )
651+
652+
653+ # =============================================================================
654+ # Pinned Numerical Tests
655+ # =============================================================================
656+
657+
658+ class TestPinnedNumerical :
659+ """Deterministic numerical tests for exact weighted formulas."""
660+
661+ def test_sdid_weighted_att_manual (self ):
662+ """Manual ATT check: survey-weighted treated means + ω∘w_co composition."""
663+ # Tiny 2x2 balanced panel: 2 control, 1 treated, 2 pre + 1 post
664+ np .random .seed (99 )
665+ data = pd .DataFrame (
666+ {
667+ "unit" : [0 , 0 , 0 , 1 , 1 , 1 , 2 , 2 , 2 ],
668+ "time" : [0 , 1 , 2 , 0 , 1 , 2 , 0 , 1 , 2 ],
669+ "outcome" : [1.0 , 2.0 , 3.0 , 2.0 , 3.0 , 4.5 , 5.0 , 6.0 , 10.0 ],
670+ "treated" : [0 , 0 , 0 , 0 , 0 , 0 , 1 , 1 , 1 ],
671+ "weight" : [1.0 , 1.0 , 1.0 , 3.0 , 3.0 , 3.0 , 2.0 , 2.0 , 2.0 ],
672+ }
673+ )
674+ # Single treated unit → treated means are trivially that unit's outcomes
675+ # (survey weight doesn't change a single-unit mean)
676+ est = SyntheticDiD (variance_method = "placebo" , n_bootstrap = 20 , seed = 42 )
677+ result = est .fit (
678+ data ,
679+ outcome = "outcome" ,
680+ treatment = "treated" ,
681+ unit = "unit" ,
682+ time = "time" ,
683+ post_periods = [2 ],
684+ survey_design = SurveyDesign (weights = "weight" ),
685+ )
686+ # Verify unit_weights sum to 1 (composed with survey)
687+ assert sum (result .unit_weights .values ()) == pytest .approx (1.0 , abs = 1e-10 )
688+ assert np .isfinite (result .att )
689+
690+ def test_trop_weighted_att_aggregation (self ):
691+ """Verify TROP ATT = weighted mean of tau values."""
692+ # Create data where we can predict directional effect of weighting
693+ np .random .seed (77 )
694+ n_units = 15
695+ n_periods = 6
696+ n_treated = 3
697+
698+ units = list (range (n_units ))
699+ periods = list (range (n_periods ))
700+
701+ rows = []
702+ for u in units :
703+ is_treated = u < n_treated
704+ base = u * 0.5
705+ for t in periods :
706+ y = base + 0.2 * t + np .random .randn () * 0.3
707+ d = 1 if (is_treated and t >= 3 ) else 0
708+ if d == 1 :
709+ # Different effect per unit: unit 0 gets +1, unit 1 gets +3, unit 2 gets +5
710+ y += 1.0 + 2.0 * u
711+ rows .append ({"unit" : u , "time" : t , "outcome" : y , "D" : d })
712+
713+ data = pd .DataFrame (rows )
714+ # Weight unit 2 (biggest effect) heavily
715+ weights = np .ones (n_units )
716+ weights [2 ] = 10.0 # unit 2 has effect ~5, heavily weighted
717+ unit_map = {u : i for i , u in enumerate (units )}
718+ data ["weight" ] = weights [data ["unit" ].map (unit_map ).values ]
719+
720+ est_no = TROP (method = "local" , n_bootstrap = 5 , seed = 42 , max_iter = 3 )
721+ result_no = est_no .fit (data , "outcome" , "D" , "unit" , "time" )
722+
723+ est_w = TROP (method = "local" , n_bootstrap = 5 , seed = 42 , max_iter = 3 )
724+ result_w = est_w .fit (
725+ data ,
726+ "outcome" ,
727+ "D" ,
728+ "unit" ,
729+ "time" ,
730+ survey_design = SurveyDesign (weights = "weight" ),
731+ )
732+
733+ # Weighted ATT should be pulled toward unit 2's larger effect
734+ assert result_w .att > result_no .att
735+
736+ def test_sdid_to_dict_schema_matches_did (self ):
737+ """SyntheticDiDResults.to_dict() survey fields match DiDResults schema."""
738+ np .random .seed (42 )
739+ data = pd .DataFrame (
740+ {
741+ "unit" : [0 , 0 , 1 , 1 , 2 , 2 ],
742+ "time" : [0 , 1 , 0 , 1 , 0 , 1 ],
743+ "outcome" : [1.0 , 2.0 , 2.0 , 3.0 , 5.0 , 8.0 ],
744+ "treated" : [0 , 0 , 0 , 0 , 1 , 1 ],
745+ "weight" : [1.0 , 1.0 , 2.0 , 2.0 , 1.5 , 1.5 ],
746+ }
747+ )
748+ est = SyntheticDiD (n_bootstrap = 10 , seed = 42 )
749+ result = est .fit (
750+ data ,
751+ "outcome" ,
752+ "treated" ,
753+ "unit" ,
754+ "time" ,
755+ post_periods = [1 ],
756+ survey_design = SurveyDesign (weights = "weight" ),
757+ )
758+ d = result .to_dict ()
759+ # Schema alignment: all these fields should be present
760+ for key in [
761+ "weight_type" ,
762+ "effective_n" ,
763+ "design_effect" ,
764+ "sum_weights" ,
765+ "n_strata" ,
766+ "n_psu" ,
767+ "df_survey" ,
768+ ]:
769+ assert key in d , f"Missing key: { key } "
0 commit comments