Fix off-by-one in score_nuisances for discrete treatments (closes #1006)#1029
Fix off-by-one in score_nuisances for discrete treatments (closes #1006)#1029immu4989 wants to merge 1 commit into
Conversation
414ea37 to
e75b3eb
Compare
kbattocchi
left a comment
There was a problem hiding this comment.
Thanks for the contribution! I really appreciate your taking the time to track down the issue and fix it, but I think the scope should be slightly broader - see comment in _rlearner.py. Once that's addressed I'd be happy to approve and merge this in.
| if self.discrete_treatment and (len(np.shape(T)) == 1 or np.shape(T)[1] == 1): | ||
| T_2_score = self.transformer.transform(np.asarray(T).reshape(-1, 1)) | ||
| else: | ||
| T_2_score = T |
There was a problem hiding this comment.
Thanks for submitting this fix!
Unfortunately, I believe the issue is slightly broader than the bug that prompted this - a similar issue will occur even with continuous treatments if a treatment featurizer is used. I think that this slight modification to your logic will cover it, but please add to one of the tests in test_treatment_featurization to also cover this logic.
| if self.discrete_treatment and (len(np.shape(T)) == 1 or np.shape(T)[1] == 1): | |
| T_2_score = self.transformer.transform(np.asarray(T).reshape(-1, 1)) | |
| else: | |
| T_2_score = T | |
| if self.transformer: | |
| T_2_score = self.transformer.transform(T) | |
| else: | |
| T_2_score = T |
|
|
||
| # compiled C files | ||
| *.c No newline at end of file | ||
| *.cscripts/ |
There was a problem hiding this comment.
Was this inadvertent? Please revert.
score_nuisances built T_2_score with pd.get_dummies(T) for discrete
treatments and passed raw T otherwise. Both paths could mismatch the
encoding the nuisance model learned at fit time:
- Discrete T: training used OneHotEncoder(drop='first'); inverse_onehot
at score time decoded the (k-column) get_dummies output as labels
{1..k} while the classifier learned {0..k-1}, silently corrupting
the reported score.
- Continuous T with a treatment_featurizer: model_t was trained on
featurized T, so passing raw T to score caused a multi-output
regressor shape mismatch.
Re-use self.transformer at score time (the same OneHotEncoder or
treatment featurizer fit during _fit_nuisances), mirroring the
existing pattern in _OrthoLearner._fit_nuisances.
Updates three assertions in test_forest_dml_score_fns whose expected
values silently encoded the discrete bug (T_MSE 1.5→0.48, T_MAE
1.0→0.48, T_R² -5.1→-0.93). Adds two regression tests:
- test_score_nuisances_discrete_treatment_label_alignment (test_dml.py)
- test_score_nuisances_applies_treatment_featurizer
(test_treatment_featurization.py)
Signed-off-by: Imran Ahamed <immu4989@gmail.com>
e75b3eb to
89b1297
Compare
|
Thanks for the review @kbattocchi — both points were on the money. Pushed an updated commit that:
Existing tests still pass: test_score_nuisances_discrete_treatment_label_alignment, test_forest_dml_score_fns, and a broader sanity sweep (27 passed across test_dml.py + test_treatment_featurization.py, no regressions outside the pre-existing *_with_ray env failures on main). Why this is a good outcome
|
kbattocchi
left a comment
There was a problem hiding this comment.
Thanks for the updates, this looks great.
#1006 (comment)
Evidence
Reproducer: ternary T (3 classes) with signal in X,
LinearDML+LogisticRegressionnuisance,cv=3. Meanscore_nuisancesacross folds:Pre-fix the classifier scores below random because
pd.get_dummies(T)keeps all k columns and
inverse_onehotdecodes labels as{1..k}whilethe classifier was trained on
{0..k-1}. Post-fix the same classifierrecovers genuine signal.
The existing
test_forest_dml_score_fnsalready exercised this path andits expected values silently encoded the bug; they're updated here to the
correct post-fix values:
T_*)mean_squared_errormean_absolute_errorr2roc_auc,log_loss, andpearsonrare approximately invariant underthe off-by-one label shift, which is why the existing test didn't fail
loud — the three metrics where label distance matters were the ones that
silently absorbed the gap.