33import math
44from typing import Any
55
6+ from .spatial_causal_estimator import build_neighbor_edges , estimate_spatial_treatment_effect
67from .utils import safe_float , truthy
78
89
@@ -30,7 +31,15 @@ def estimate_observational_treatment_effect(records: list[dict[str, Any]], *, th
3031 aipw_effect , aipw_se , influence = _augmented_ipw_ate (usable )
3132 balance = _covariate_balance (usable )
3233 overlap = _overlap_diagnostics (usable , thresholds )
33- spatial = _spatial_interference_diagnostics (usable , influence , thresholds )
34+ neighbor_edges = build_neighbor_edges (usable , thresholds )
35+ spatial = _spatial_interference_diagnostics (usable , influence , thresholds , neighbor_edges = neighbor_edges )
36+ spatial_estimator = estimate_spatial_treatment_effect (
37+ usable ,
38+ thresholds = thresholds ,
39+ neighbor_edges = neighbor_edges ,
40+ observational_effect = aipw_effect ,
41+ observational_standard_error = aipw_se ,
42+ )
3443
3544 primary_name , primary_effect , primary_se = _primary_estimator (
3645 naive_effect = naive_effect ,
@@ -45,6 +54,7 @@ def estimate_observational_treatment_effect(records: list[dict[str, Any]], *, th
4554 control_count = len (control ),
4655 thresholds = thresholds ,
4756 overlap = overlap ,
57+ spatial_estimator = spatial_estimator ,
4858 )
4959 model_effects = [safe_float (row .get ("model_effect" ), None ) for row in usable ]
5060 model_effects = [float (item ) for item in model_effects if item is not None ]
@@ -85,6 +95,7 @@ def estimate_observational_treatment_effect(records: list[dict[str, Any]], *, th
8595 "overlap" : overlap ,
8696 "balance" : balance ,
8797 "spatial" : spatial ,
98+ "spatial_estimator" : spatial_estimator ,
8899 }
89100
90101
@@ -249,7 +260,21 @@ def _primary_estimator(
249260 control_count : int ,
250261 thresholds : dict [str , Any ],
251262 overlap : dict [str , Any ],
263+ spatial_estimator : dict [str , Any ] | None = None ,
252264) -> tuple [str , float , float ]:
265+ if (
266+ spatial_estimator
267+ and spatial_estimator .get ("status" ) == "pass"
268+ and usable_count >= int (thresholds .get ("min_records" , 8 ))
269+ and treated_count >= int (thresholds .get ("min_treated" , 3 ))
270+ and control_count >= int (thresholds .get ("min_control" , 3 ))
271+ and overlap .get ("status" ) == "pass"
272+ ):
273+ return (
274+ "spatial_fixed_effect_neighbor_adapter" ,
275+ float (spatial_estimator .get ("effect" ) or 0.0 ),
276+ float (spatial_estimator .get ("standard_error" ) or 0.0 ),
277+ )
253278 if (
254279 usable_count >= int (thresholds .get ("min_records" , 8 ))
255280 and treated_count >= int (thresholds .get ("min_treated" , 3 ))
@@ -312,7 +337,13 @@ def _overlap_diagnostics(usable: list[dict[str, Any]], thresholds: dict[str, Any
312337 }
313338
314339
315- def _spatial_interference_diagnostics (usable : list [dict [str , Any ]], influence : list [float ], thresholds : dict [str , Any ]) -> dict [str , Any ]:
340+ def _spatial_interference_diagnostics (
341+ usable : list [dict [str , Any ]],
342+ influence : list [float ],
343+ thresholds : dict [str , Any ],
344+ * ,
345+ neighbor_edges : list [tuple [int , int , float ]] | None = None ,
346+ ) -> dict [str , Any ]:
316347 spatial_rows = [row for row in usable if row .get ("spatial" )]
317348 if not spatial_rows :
318349 return {
@@ -323,7 +354,7 @@ def _spatial_interference_diagnostics(usable: list[dict[str, Any]], influence: l
323354 "note" : "no spatial coordinates, cluster ids or neighbor links supplied" ,
324355 }
325356
326- neighbor_edges = _neighbor_edges ( usable , thresholds )
357+ neighbor_edges = list ( neighbor_edges or [] )
327358 cluster_summary = _spatial_cluster_summary (usable )
328359 exposure = _neighborhood_exposure (usable , neighbor_edges )
329360 moran = _moran_like_residual_correlation (usable , influence , neighbor_edges )
@@ -366,34 +397,18 @@ def _spatial_attributes(row: dict[str, Any]) -> dict[str, Any]:
366397 cluster = row .get ("spatial_cluster" ) or row .get ("cluster" ) or row .get ("block_id" ) or row .get ("township_id" )
367398 if cluster is not None :
368399 spatial ["cluster" ] = str (cluster )
369- neighbors = row .get ("neighbors" ) or row .get ("neighbor_unit_ids" ) or []
370- if isinstance ( neighbors , ( list , tuple , set )) and neighbors :
371- spatial ["neighbors" ] = [ str ( item ) for item in neighbors ]
400+ neighbors = _neighbor_ids ( row .get ("neighbors" ) or row .get ("neighbor_unit_ids" ) or [])
401+ if neighbors :
402+ spatial ["neighbors" ] = neighbors
372403 return spatial
373404
374405
375- def _neighbor_edges (usable : list [dict [str , Any ]], thresholds : dict [str , Any ]) -> list [tuple [int , int , float ]]:
376- index_by_id = {str (row .get ("unit_id" ) or idx ): idx for idx , row in enumerate (usable )}
377- edges : dict [tuple [int , int ], float ] = {}
378- for idx , row in enumerate (usable ):
379- spatial = dict (row .get ("spatial" ) or {})
380- for neighbor_id in spatial .get ("neighbors" ) or []:
381- other = index_by_id .get (str (neighbor_id ))
382- if other is None or other == idx :
383- continue
384- pair = tuple (sorted ((idx , other )))
385- edges [pair ] = 1.0
386-
387- distance_threshold = safe_float (thresholds .get ("spatial_neighbor_distance" ), None )
388- coordinate_rows = [(idx , dict (row .get ("spatial" ) or {})) for idx , row in enumerate (usable ) if "x" in dict (row .get ("spatial" ) or {}) and "y" in dict (row .get ("spatial" ) or {})]
389- if distance_threshold is not None and distance_threshold > 0 and len (coordinate_rows ) > 1 :
390- for pos , (idx , left ) in enumerate (coordinate_rows ):
391- for other , right in coordinate_rows [pos + 1 :]:
392- distance = math .sqrt ((float (left ["x" ]) - float (right ["x" ])) ** 2 + (float (left ["y" ]) - float (right ["y" ])) ** 2 )
393- if distance <= float (distance_threshold ):
394- pair = tuple (sorted ((idx , other )))
395- edges [pair ] = 1.0 / max (distance , 1e-9 )
396- return [(left , right , weight ) for (left , right ), weight in sorted (edges .items ())]
406+ def _neighbor_ids (value : Any ) -> list [str ]:
407+ if isinstance (value , (list , tuple , set )):
408+ return [str (item ).strip () for item in value if str (item ).strip ()]
409+ if isinstance (value , str ):
410+ return [item .strip () for item in value .replace (";" , "," ).split ("," ) if item .strip ()]
411+ return []
397412
398413
399414def _spatial_cluster_summary (usable : list [dict [str , Any ]]) -> dict [str , Any ]:
0 commit comments