@@ -241,7 +241,8 @@ def fit(
241241
242242 self ._treatment_values = np .unique (T )
243243 self ._feature_names = [f'X{ i } ' for i in range (X .shape [1 ])]
244- self ._X_original = X .copy () # Store original X for predict method
244+ self ._X_original = X .copy ()
245+ self ._T_original = T .copy ()
245246
246247 # Validate treatment
247248 if self .discrete_treatment :
@@ -501,19 +502,9 @@ def _replace_leaf_values_with_causal_effects(
501502 else :
502503 causal_effect = 0.0
503504
504- # Update tree leaf value
505- # Note: This is a simplification - in practice, we'd need to
506- # store these values separately and use them in prediction
507- leaf_mask = (tree .tree_ .children_left == - 1 ) & (tree .tree_ .children_right == - 1 )
508-
509- # sklearn decision trees store values as (n_nodes, n_outputs, n_values)
510- # For regression, this is typically (n_nodes, 1, 1)
511- # We need to update all leaf nodes with the causal effect
512- for i in range (len (tree .tree_ .value )):
513- if leaf_mask [i ]:
514- # Set the leaf value to causal_effect, maintaining original shape
515- original_shape = tree .tree_ .value [i ].shape
516- tree .tree_ .value [i ] = np .full (original_shape , causal_effect )
505+ # Update THIS leaf's value (leaf_id is the node index in tree.tree_)
506+ original_shape = tree .tree_ .value [leaf_id ].shape
507+ tree .tree_ .value [leaf_id ] = np .full (original_shape , causal_effect )
517508
518509 def effect (self , X : np .ndarray ) -> np .ndarray :
519510 """
@@ -692,6 +683,94 @@ def __repr__(self) -> str:
692683 """Detailed string representation"""
693684 return self .__str__ ()
694685
686+ # ------------------------------------------------------------------ #
687+ # GRF-inspired extensions
688+ # ------------------------------------------------------------------ #
689+
690+ def variable_importance (self ) -> pd .Series :
691+ """Permutation-based variable importance for the causal forest.
692+
693+ For each feature j, shuffle its column in the effect-modifier
694+ matrix and measure how much the cross-validated CATE predictions
695+ degrade (MSE increase). Higher degradation → more important for
696+ treatment-effect heterogeneity.
697+
698+ Returns a normalised importance score (sums to 1).
699+ """
700+ if not self .fitted_ :
701+ raise ValueError ("Model must be fitted before computing importance" )
702+ X = self ._X_original .copy ()
703+ cate_baseline = self .effect (X )
704+ n , k = X .shape
705+ rng = np .random .default_rng (0 )
706+ importance = np .empty (k )
707+ for j in range (k ):
708+ X_perm = X .copy ()
709+ X_perm [:, j ] = rng .permutation (X_perm [:, j ])
710+ cate_perm = self .effect (X_perm )
711+ importance [j ] = float (np .mean ((cate_baseline - cate_perm ) ** 2 ))
712+ total = importance .sum ()
713+ importance = importance / total if total > 0 else importance
714+ names = self ._feature_names or [f"X{ j } " for j in range (k )]
715+ return pd .Series (importance , index = names ).sort_values (ascending = False )
716+
717+ def best_linear_projection (
718+ self ,
719+ X_test : Optional [np .ndarray ] = None ,
720+ alpha : float = 0.05 ,
721+ ) -> pd .DataFrame :
722+ """Best Linear Projection (BLP) heterogeneity test (Chernozhukov et al. 2020).
723+
724+ Regress CATE(X_i) on the features X_i:
725+
726+ CATE_i = β₀ + X_i' β₁ + ε_i
727+
728+ A joint F-test on β₁ = 0 tests for systematic treatment-effect
729+ heterogeneity. Individual t-tests indicate which features drive it.
730+
731+ Returns a DataFrame with coefficient, SE, t-stat, p-value per feature.
732+ """
733+ if not self .fitted_ :
734+ raise ValueError ("Model must be fitted first" )
735+ X = X_test if X_test is not None else self ._X_original
736+ X = np .asarray (X )
737+ cate = self .effect (X )
738+ n , k = X .shape
739+ # Standardise X so coefficients are comparable
740+ X_std = (X - X .mean (axis = 0 )) / (X .std (axis = 0 ) + 1e-12 )
741+ D = np .column_stack ([np .ones (n ), X_std ])
742+ DtD_inv = np .linalg .inv (D .T @ D )
743+ beta = DtD_inv @ (D .T @ cate )
744+ e = cate - D @ beta
745+ sigma2 = float (e @ e ) / (n - k - 1 )
746+ se = np .sqrt (np .diag (sigma2 * DtD_inv ))
747+ tvals = beta / se
748+ from scipy import stats
749+ pvals = 2 * (1 - stats .t .cdf (np .abs (tvals ), df = n - k - 1 ))
750+ names = ["Intercept" ] + (
751+ self ._feature_names or [f"X{ j } " for j in range (k )]
752+ )
753+ return pd .DataFrame ({
754+ "coef" : beta , "se" : se , "t" : tvals , "p" : pvals ,
755+ }, index = names )
756+
757+ def ate (self , X : Optional [np .ndarray ] = None ) -> float :
758+ """Average Treatment Effect (mean CATE)."""
759+ return float (self .effect (X if X is not None else self ._X_original ).mean ())
760+
761+ def att (self , X : Optional [np .ndarray ] = None ,
762+ T : Optional [np .ndarray ] = None ) -> float :
763+ """Average Treatment Effect on the Treated."""
764+ if T is None :
765+ if hasattr (self , "_T_original" ):
766+ T = self ._T_original
767+ else :
768+ raise ValueError ("Treatment vector needed for ATT" )
769+ X = X if X is not None else self ._X_original
770+ cate = self .effect (X )
771+ mask = np .asarray (T ).ravel () == 1
772+ return float (cate [mask ].mean ())
773+
695774
696775def causal_forest (
697776 formula : Optional [str ] = None ,
0 commit comments