Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parametrize evaluate #8

Merged
merged 49 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e8b64e6
Speedup tests
FrancescMartiEscofetQC Jun 14, 2024
7a11445
Switch `strict` meaning in `validate_number_positive`
FrancescMartiEscofetQC Jun 14, 2024
642cb2e
Add classes_ to cfe
FrancescMartiEscofetQC Jun 14, 2024
d7cef73
Fix RLoss calculation in evaluate
FrancescMartiEscofetQC Jun 13, 2024
1234a0b
Merge pull request #3 from Quantco/speedup_tests
FrancescMartiEscofetQC Jun 14, 2024
8efba91
Merge pull request #4 from Quantco/issue_162
FrancescMartiEscofetQC Jun 14, 2024
32c721d
Merge branch 'main' into cfe_classes_
FrancescMartiEscofetQC Jun 14, 2024
963debf
Parametrize evaluate
FrancescMartiEscofetQC Jun 14, 2024
dc93dd1
Merge branch 'fix_r_evaluate' into parametrize_evaluate
FrancescMartiEscofetQC Jun 14, 2024
6a4cd07
Merge branch 'main' into fix_r_evaluate
FrancescMartiEscofetQC Jun 14, 2024
e3df56a
Merge branch 'cfe_classes_' into fix_r_evaluate
FrancescMartiEscofetQC Jun 14, 2024
1a93bfa
Merge branch 'fix_r_evaluate' into parametrize_evaluate
FrancescMartiEscofetQC Jun 14, 2024
ad71c66
run pchs
FrancescMartiEscofetQC Jun 14, 2024
e0a9239
Update CHANGELOG
FrancescMartiEscofetQC Jun 14, 2024
a5f657d
Merge branch 'main' into cfe_classes_
FrancescMartiEscofetQC Jun 17, 2024
9992576
Merge branch 'cfe_classes_' into fix_r_evaluate
FrancescMartiEscofetQC Jun 17, 2024
f6c7d74
Merge branch 'fix_r_evaluate' into parametrize_evaluate
FrancescMartiEscofetQC Jun 17, 2024
a38ca89
Merge branch 'main' into parametrize_evaluate
kklein Jun 18, 2024
d6327ae
Merge branch 'main' into parametrize_evaluate
FrancescMartiEscofetQC Jun 18, 2024
476a4ae
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
1c4c060
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
49f1556
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
d528045
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
631505e
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
e0e70fa
Fix naming
FrancescMartiEscofetQC Jun 24, 2024
e0cd563
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
fc01491
Fix docs
FrancescMartiEscofetQC Jun 24, 2024
0150106
Don't force subset
FrancescMartiEscofetQC Jun 24, 2024
6b595bd
Add test to ignore
FrancescMartiEscofetQC Jun 24, 2024
4ac9027
Merge branch 'main' into parametrize_evaluate
FrancescMartiEscofetQC Jun 24, 2024
19f895c
Centralize generation of default scoring (#22)
kklein Jun 24, 2024
12d41b5
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
4a36e25
Update metalearners/tlearner.py
FrancescMartiEscofetQC Jun 24, 2024
5f0987f
Update metalearners/xlearner.py
FrancescMartiEscofetQC Jun 24, 2024
d76dc74
Update metalearners/metalearner.py
FrancescMartiEscofetQC Jun 24, 2024
05787f9
Rename
FrancescMartiEscofetQC Jun 24, 2024
dc946dc
Rename
FrancescMartiEscofetQC Jun 24, 2024
ba895a3
Rename
FrancescMartiEscofetQC Jun 24, 2024
e81d152
Rename
FrancescMartiEscofetQC Jun 24, 2024
9d2bbb9
Rename
FrancescMartiEscofetQC Jun 24, 2024
c4de4f1
Rename
FrancescMartiEscofetQC Jun 24, 2024
7fa8794
Update metalearners/drlearner.py
FrancescMartiEscofetQC Jun 24, 2024
8691a02
Update metalearners/_utils.py
FrancescMartiEscofetQC Jun 24, 2024
ecfd745
Merge branch 'main' into parametrize_evaluate
kklein Jun 24, 2024
5fcc3ec
Merge branch 'main' into parametrize_evaluate
kklein Jun 25, 2024
d06e003
Merge branch 'main' into parametrize_evaluate
kklein Jun 25, 2024
d38e9d5
Update CHANGELOG
FrancescMartiEscofetQC Jun 25, 2024
75dd120
Merge branch 'main' into parametrize_evaluate
FrancescMartiEscofetQC Jun 26, 2024
c20ae75
Add option to evaluate treatment model in RLearner
FrancescMartiEscofetQC Jun 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Changelog
0.4.0 (2024-06-18)
------------------

* Added ``scoring`` parameter to :meth:`metalearners.metalearner.MetaLearner.evaluate` and
implemented the abstract method for the :class:`metalearners.XLearner` and
:class:`metalearners.DRLearner`.

* Implemented :meth:`metalearners.cross_fit_estimator.CrossFitEstimator.clone`.

* Added ``n_jobs_base_learners`` to :meth:`metalearners.metalearner.MetaLearner.fit`.
Expand Down
17 changes: 17 additions & 0 deletions metalearners/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,3 +463,20 @@ def simplify_output_2d(tensor: np.ndarray) -> np.ndarray:
"This function requires a regression or a classification with binary outcome "
"task."
)


# Taken from https://stackoverflow.com/questions/13741998/is-there-a-way-to-let-classes-inherit-the-documentation-of-their-superclass-with
def copydoc(fromfunc, sep="\n"):
"""
Decorator: Copy the docstring of `fromfunc`
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
"""

def _decorator(func):
sourcedoc = fromfunc.__doc__
if func.__doc__ is None:
func.__doc__ = sourcedoc
else:
func.__doc__ = sep.join([sourcedoc, func.__doc__])
return func

return _decorator
9 changes: 7 additions & 2 deletions metalearners/cross_fit_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,12 +362,17 @@ def __init__(
self.original_predict_proba = model.predict_proba

def __enter__(self):
self.model.predict = partial( # type: ignore
new_predict = partial(
self.model.predict, is_oos=self.is_oos, oos_method=self.oos_method
)
self.model.predict_proba = partial( # type: ignore
new_predict.__name__ = "predict" # type: ignore
self.model.predict = new_predict # type: ignore

new_predict_proba = partial(
self.model.predict_proba, is_oos=self.is_oos, oos_method=self.oos_method
)
new_predict_proba.__name__ = "predict_proba" # type: ignore
self.model.predict_proba = new_predict_proba # type: ignore
return self.model

def __exit__(self, *args):
Expand Down
79 changes: 68 additions & 11 deletions metalearners/drlearner.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# # Copyright (c) QuantCo 2024-2024
# # SPDX-License-Identifier: BSD-3-Clause

from collections.abc import Callable, Mapping

import numpy as np
from joblib import Parallel, delayed
from typing_extensions import Self
Expand All @@ -23,6 +25,7 @@
VARIANT_OUTCOME_MODEL,
MetaLearner,
_ConditionalAverageOutcomeMetaLearner,
_evaluate_model,
_fit_cross_fit_estimator_joblib,
_ModelSpecifications,
_ParallelJoblibSpecification,
Expand Down Expand Up @@ -148,6 +151,7 @@ def fit(
w=w,
y=y,
treatment_variant=treatment_variant,
is_oos=False,
)

treatment_jobs.append(
Expand Down Expand Up @@ -205,37 +209,90 @@ def evaluate(
w: Vector,
is_oos: bool,
oos_method: OosMethod = OVERALL,
) -> dict[str, float | int]:
raise NotImplementedError(
"This feature is not yet implemented for the DR-Learner."
scoring: Mapping[str, list[str | Callable]] | None = None,
) -> dict[str, float]:
if scoring is None:
scoring = {}
self._validate_scoring(scoring=scoring)

default_metric = (
"neg_log_loss" if self.is_classification else "neg_root_mean_squared_error"
)
masks = []
for tv in range(self.n_variants):
masks.append(w == tv)
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
variant_outcome_evaluation = _evaluate_model(
cfes=self._nuisance_models[VARIANT_OUTCOME_MODEL],
X=[X[w == tv] for tv in range(self.n_variants)],
y=[y[w == tv] for tv in range(self.n_variants)],
scorers=scoring.get(VARIANT_OUTCOME_MODEL, [default_metric]),
model_kind=VARIANT_OUTCOME_MODEL,
is_oos=is_oos,
oos_method=oos_method,
is_treatment=False,
)

propensity_evaluation = _evaluate_model(
cfes=self._nuisance_models[PROPENSITY_MODEL],
X=[X],
y=[w],
scorers=scoring.get(PROPENSITY_MODEL, ["neg_log_loss"]),
model_kind=PROPENSITY_MODEL,
is_oos=is_oos,
oos_method=oos_method,
is_treatment=False,
)

pseudo_outcome: list[np.ndarray] = []
for treatment_variant in range(1, self.n_variants):
tv_pseudo_outcome = self._pseudo_outcome(
X=X,
y=y,
w=w,
treatment_variant=treatment_variant,
is_oos=is_oos,
oos_method=oos_method,
)
pseudo_outcome.append(tv_pseudo_outcome)

treatment_evaluation = _evaluate_model(
self._treatment_models[TREATMENT_MODEL],
X=[X for _ in range(1, self.n_variants)],
y=pseudo_outcome,
scorers=scoring.get(TREATMENT_MODEL, ["neg_root_mean_squared_error"]),
model_kind=TREATMENT_MODEL,
is_oos=is_oos,
oos_method=oos_method,
is_treatment=True,
)

return variant_outcome_evaluation | propensity_evaluation | treatment_evaluation

def _pseudo_outcome(
self,
X: Matrix,
y: Vector,
w: Vector,
treatment_variant: int,
is_oos: bool,
oos_method: OosMethod = OVERALL,
epsilon: float = _EPSILON,
) -> np.ndarray:
"""Compute the DR-Learner pseudo outcome.

Importantly, this method assumes to be applied on in-sample data.
In other words, ``is_oos`` will always be set to ``False`` when calling
``predict_nuisance``.
"""
"""Compute the DR-Learner pseudo outcome."""
validate_valid_treatment_variant_not_control(treatment_variant, self.n_variants)

conditional_average_outcome_estimates = (
self.predict_conditional_average_outcomes(
X=X,
is_oos=False,
is_oos=is_oos,
oos_method=oos_method,
)
)

propensity_estimates = self.predict_nuisance(
X=X,
is_oos=False,
is_oos=is_oos,
oos_method=oos_method,
model_kind=PROPENSITY_MODEL,
model_ord=0,
)
Expand Down
84 changes: 81 additions & 3 deletions metalearners/metalearner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
# # SPDX-License-Identifier: BSD-3-Clause

from abc import ABC, abstractmethod
from collections.abc import Callable, Collection
from collections.abc import Callable, Collection, Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
from typing import TypedDict

import numpy as np
import pandas as pd
import shap
from sklearn.metrics import get_scorer
from sklearn.model_selection import KFold
from typing_extensions import Self

Expand All @@ -32,6 +33,7 @@
from metalearners.cross_fit_estimator import (
OVERALL,
CrossFitEstimator,
_PredictContext,
)
from metalearners.explainer import Explainer

Expand Down Expand Up @@ -133,6 +135,41 @@ def _validate_n_folds_synchronize(n_folds: dict[str, int]) -> None:
raise ValueError("Need at least two folds to use synchronization.")


def _evaluate_model(
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
cfes: Sequence[CrossFitEstimator],
X: Sequence[Matrix],
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
y: Sequence[Vector],
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
scorers: Sequence[str | Callable],
model_kind: str,
is_oos: bool,
is_treatment: bool,
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
oos_method: OosMethod = OVERALL,
) -> dict[str, float]:
"""Helper function to evaluate all the models of the same model kind."""
prefix = f"{model_kind}_"
evaluation_metrics: dict[str, float] = {}
for idx, scorer in enumerate(scorers):
if isinstance(scorer, str):
scorer_str = scorer
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
scorer_call: Callable = get_scorer(scorer)
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved
else:
scorer_str = f"custom_scorer_{idx}"
scorer_call = scorer
for i, cfe in enumerate(cfes):
if is_treatment:
kklein marked this conversation as resolved.
Show resolved Hide resolved
treatment_variant = i + 1
index_str = f"{treatment_variant}_vs_0_"
else:
if len(cfes) == 1:
index_str = ""
else:
index_str = f"{i}_"
name = f"{prefix}{index_str}{scorer_str}"
kklein marked this conversation as resolved.
Show resolved Hide resolved
with _PredictContext(cfe, is_oos, oos_method) as modified_cfe:
evaluation_metrics[name] = scorer_call(modified_cfe, X[i], y[i])
return evaluation_metrics


class _ModelSpecifications(TypedDict):
# The quotes on MetaLearner are necessary for type hinting as it's not yet defined
# here. Check https://stackoverflow.com/questions/55320236/does-python-evaluate-type-hinting-of-a-forward-reference
Expand Down Expand Up @@ -311,6 +348,16 @@ def _validate_models(self) -> None:
factory, predict_method, name=f"treatment model {model_kind}"
)

@classmethod
def _validate_scoring(cls, scoring: Mapping[str, list[str | Callable]]):
if not set(scoring.keys()) <= (
set(cls.nuisance_model_specifications().keys())
| set(cls.treatment_model_specifications().keys())
):
raise ValueError(
"scoring dict keys need to be a subset of the model names in the MetaLearner"
kklein marked this conversation as resolved.
Show resolved Hide resolved
)

def _qualified_fit_params(
self,
fit_params: None | dict,
Expand Down Expand Up @@ -824,8 +871,39 @@ def evaluate(
w: Vector,
is_oos: bool,
oos_method: OosMethod = OVERALL,
) -> dict[str, float | int]:
"""Evaluate all models contained in a MetaLearner."""
scoring: Mapping[str, list[str | Callable]] | None = None,
) -> dict[str, float]:
r"""Evaluate the models models contained in the MetaLearner.
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved

``scoring`` keys must be a subset of the names of the models contained in the
MetaLearner, for information about this names check :meth:`~metalearners.metalearner.MetaLearner.nuisance_model_specifications`
and :meth:`~metalearners.metalearner.MetaLearner.treatment_model_specifications`.
The values must be a list of:

* ``string`` representing a ``sklearn`` scoring method. Check
`here <https://scikit-learn.org/stable/modules/model_evaluation.html#common-cases-predefined-values>`__
kklein marked this conversation as resolved.
Show resolved Hide resolved
for the possible values.
* ``Callable`` with signature ``scorer(estimator, X, y_true, **kwargs)``. We recommend
using `sklearn.metrics.make_scorer <https://scikit-learn.org/stable/modules/generated/sklearn.metrics.make_scorer.html>`_
to create this callables.
FrancescMartiEscofetQC marked this conversation as resolved.
Show resolved Hide resolved

If some model name is not present in the keys of ``scoring`` then the default used
metrics will be ``neg_log_loss`` if it is a classifier and ``neg_root_mean_squared_error``
if it is a regressor.

The returned dictionary keys have the following structure:

* For nuisance models:

* If the cardinality is one: ``f"{model_kind}_{scorer}"``
* If there is one model for each treatment variant (including control):
``f"{model_kind}_{treatment_variant}_{scorer}"``

* For treatment models: ``f"{model_kind}_{treatment_variant}_vs_0_{scorer}"``

Where ``scorer`` is the name of the scorer if it is a string and ``"custom_scorer_{idx}"``
if it is a callable where ``idx`` is the index in the ``scorers`` list.
"""
...

def explainer(
Expand Down
Loading