Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2c862c0
Add posterior_predict and better support in marginalEffects.plot
jwdink Feb 17, 2023
1e33cda
Merge branch 'develop' into feature/posterior_predict
jwdink Nov 16, 2023
b20fbca
fix issues in posterior_predict and ME integration
jwdink Nov 16, 2023
6473125
restore original if issue
jwdink Nov 16, 2023
5de3186
Merge pull request #31 from strongio/feature/posterior_predict
jwdink Nov 16, 2023
7fad90a
fix bug in how simulate_data's random_state is used for torch
jwdink Dec 1, 2023
239d3e0
update pin so notebook isnt broken
jwdink Dec 15, 2023
8abde8a
missing comma
jwdink Dec 15, 2023
33e847a
add todo
jwdink Feb 25, 2025
0e565aa
Merge branch 'main' into develop
jwdink Apr 9, 2025
bc54771
update as_transformer
jwdink Apr 24, 2025
2b6db1d
handle new sklearn indexing behavior
jwdink Jan 27, 2026
10fb7c4
make sure sklearn indexes SliceDict with pandas indexing if values ar…
jwdink Jan 27, 2026
134c49e
avoid pandas deprecation warning for sparse
jwdink May 23, 2026
344fa02
fix merge
jwdink Jul 16, 2026
8b72e6b
fix merge
jwdink Jul 16, 2026
67f2506
fix merge
jwdink Jul 17, 2026
1edaad8
add metalearner base class
jwdink Jul 20, 2026
06a2dc4
Merge pull request #42 from onesixsolutions/feature/metalearner-base
rmorton8 Jul 20, 2026
9a133f1
safe_predict allows len(array.shape)==1
jwdink Jul 22, 2026
e8a5645
add iloc to SliceDict after super.init call
jwdink Jul 22, 2026
a2ac023
add an __array__ method
jwdink Jul 22, 2026
fa21624
Merge branch 'feature/one-se-rule' into develop
jwdink Aug 7, 2026
06dcc7c
add more supported estimators
jwdink Aug 7, 2026
6567578
check for need for complexity_reduce_fun later
jwdink Aug 7, 2026
37b3ac3
defer check
jwdink Aug 7, 2026
3207bfb
allow specifying the threshold directly
jwdink Aug 7, 2026
53d5a82
Create composite_model.py
jwdink Aug 11, 2026
533fa22
Update __init__.py
jwdink Aug 11, 2026
97c09e2
Merge branch 'feature/composite-model' into develop
jwdink Aug 11, 2026
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
12 changes: 7 additions & 5 deletions docs/README.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.14.4
# jupytext_version: 1.15.2
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
Expand Down Expand Up @@ -110,15 +110,17 @@
# By default, the `Glm` will estimate not just the parameters of our model, but also the uncertainty associated with them. We can access a dataframe of these with the `coef_dataframe_` attribute:

# %%
df_coefs = glm[-1].coef_dataframe_

# %%
df_coefs = glm[-1].coef_dataframe_.copy()
df_coefs = df_coefs[~df_coefs['name'].str.startswith('probs_bias')].reset_index(drop=True)
df_coefs[['param', 'trans', 'term']] = df_coefs['name'].str.split('__', n=3, expand=True)
df_coefs

# %% [markdown]
# Using this, it's easy to plot our model-coefficients:

# %%
df_coefs[['param', 'trans', 'term']] = df_coefs['name'].str.split('__', n=3, expand=True)

df_coefs[df_coefs['name'].str.contains('page_feat')].plot('term', 'estimate', kind='bar', yerr='se')
df_coefs[df_coefs['name'].str.contains('user_agent_platform')].plot('term', 'estimate', kind='bar', yerr='se')

Expand All @@ -129,7 +131,7 @@

# %%
glm_me = MarginalEffects(glm)
glm_me.fit(
glm_me(
X=df_val_expanded,
y=df_val_expanded['is_click'],
vary_features=['page_feat3']
Expand Down
2 changes: 1 addition & 1 deletion foundry/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.2.10'
__version__ = '0.2.11'
212 changes: 212 additions & 0 deletions foundry/composite_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import keyword
from typing import Sequence, Union, Collection
from warnings import warn

import numpy as np
import pandas as pd
from foundry.util import to_1d, safe_predict
from sklearn.utils import _safe_indexing
from sklearn.utils.metaestimators import _BaseComposition
from sklearn.base import BaseEstimator, clone


class CompositeModel(_BaseComposition):
r"""
A model whose predictions are generated by combining the predictions of sub-models in a deterministic fashion (such
as a hurdle model).

Subclasses should implement a ``sub_model_names`` class attribute, the ``_combine_predictions()`` method, and the
``_score()`` method.

Declaring a subclass needs nothing but ``sub_model_names`` and the two abstract methods -- no ``__init__``::

>>> class HurdleModel(CompositeModel):
... sub_model_names = ['event_est', 'value_given_event_est']
...
... def _combine_predictions(self, components):
... return components['event_est'] * components['value_given_event_est']
...
... def _score(self, X, y, sample_weight=None):
... from sklearn.metrics import r2_score
... return r2_score(y['value_given_event_est'], self.predict(X), sample_weight=sample_weight)

Subclassing magic
------------------
Setting ``sub_model_names`` on a subclass triggers ``__init_subclass__``, which does two things:

1. For each name in ``sub_model_names``, adds two read-only properties: ``<name>`` (the *unfitted* template,
read from ``self.sub_models``) and ``<name>_`` (the *fitted* sub-model, read from ``self.sub_models_`` --
only available after calling ``fit()``, per sklearn's trailing-underscore convention).
2. If the subclass does *not* define its own ``__init__``, one is generated automatically with a named
parameter for each entry in ``sub_model_names`` -- so subclasses get an explicit, autocomplete-friendly
constructor for free, equivalent to calling ``super().__init__(dict(...))`` by hand. Writing your own
``__init__`` (e.g. for extra validation or extra arguments) opts out of this entirely; nothing below
applies in that case.

Even though no ``__init__`` was written, one was generated, so construction reads like an ordinary, explicit
constructor -- not the generic ``sub_models=`` form::

>>> from sklearn.linear_model import LogisticRegression, LinearRegression
>>> model = HurdleModel(
... event_est=LogisticRegression(),
... value_given_event_est=LinearRegression(),
... )
>>> model.event_est # unfitted template, via the auto-added property
>>> # LogisticRegression()

After fitting, the fitted sub-models are available, and because the generated ``__init__`` signature lines up
exactly with what ``get_params()`` reports, ``clone()``/``get_params()``/``set_params()`` all work normally --
no extra effort required despite the friendlier constructor::

>>> model.fit(X_train, y={'event_est': y_event, 'value_given_event_est': y_value})
>>> model.event_est_ # the *fitted* estimator (distinct object from model.event_est)
>>> # LogisticRegression()
>>> from sklearn.base import clone
>>> clone(model) # round-trips correctly through get_params() -> __init__
>>> # HurdleModel(event_est=LogisticRegression(), value_given_event_est=LinearRegression())
"""
sub_model_names: Sequence[str]

def __init__(self, sub_models: Union[dict, Sequence[tuple]]):
if isinstance(sub_models, dict):
sub_models = list(sub_models.items())
self.sub_models = sub_models

def predict(self, X, **kwargs):
components = self.predict_components(X, **kwargs)
return self._combine_predictions(components)

def _combine_predictions(self, components):
raise NotImplementedError

def predict_components(self, X, **kwargs):
kwargs_per_model = _organize_owned_kwargs(kwargs, possible_owners=set(self.sub_model_names))
out = {}
for model_name, model in self.sub_models_:
out[model_name] = to_1d(safe_predict(model, X, **kwargs_per_model[model_name]))
return out

def fit(self, X, y, verbose=False, **kwargs):
if kwargs.get('sample_weight', None) is None and isinstance(y, dict) and 'sample_weight' in y:
y = y.copy()
kwargs['sample_weight'] = y.pop('sample_weight')

sub_model_names, _ = zip(*self.sub_models)
kwargs_per_model = _organize_owned_kwargs(kwargs, possible_owners=set(sub_model_names))

self.sub_models_ = []
for nm in self.sub_model_names:
if verbose:
print(f"Fitting {nm}...")
model_template = dict(self.sub_models).get(nm, None)
if model_template is None:
raise ValueError(f"A model for `{nm}` was not provided at init")

this_y = y[nm]
_is_valid = self._get_valid_mask(this_y)
this_y = _safe_indexing(this_y, _is_valid)
this_X = _safe_indexing(X, _is_valid)
this_kwargs = kwargs_per_model[nm].copy()
for k in list(this_kwargs):
if k == 'sample_weight' or k.endswith('__sample_weight'):
this_kwargs[k] = _safe_indexing(this_kwargs[k], _is_valid)

fitted = clone(model_template).fit(this_X, this_y, **this_kwargs)
self.sub_models_.append((nm, fitted))
return self

def _get_valid_mask(self, y):
return np.array(pd.notnull(y))

def get_params(self, deep: bool = True) -> dict:
return self._get_params('sub_models', deep=deep)

def set_params(self, **kwargs):
self._set_params('sub_models', **kwargs)
return self

def score(self, X, y, sample_weight=None, score_sub_models: bool = False):
score = self._score(X=X, y=y, sample_weight=sample_weight)
if not score_sub_models:
return score
scores = {'combined': score}
for nm, model in self.sub_models_:
y_true = y[nm]
mask = self._get_valid_mask(y_true)
scores[nm] = model.score(
_safe_indexing(X, mask),
_safe_indexing(y_true, mask),
sample_weight=None if sample_weight is None else _safe_indexing(sample_weight, mask)
)
return scores

def _score(self, X, y, sample_weight=None):
raise NotImplementedError

def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
for sub_model_name in cls.sub_model_names:
setattr(cls, sub_model_name, property(_GetHelper(sub_model_name, 'sub_models')))
setattr(cls, sub_model_name + '_', property(_GetHelper(sub_model_name, 'sub_models_')))

if '__init__' not in cls.__dict__:
cls.__init__ = _build_nice_init(cls)


class _GetHelper:
def __init__(self, sub_model_name: str, dict_attr_name: str):
self.sub_model_name = sub_model_name
self.dict_attr_name = dict_attr_name

def __call__(self, instance) -> BaseEstimator:
return dict(getattr(instance, self.dict_attr_name))[self.sub_model_name]


_RESERVED_NAMES = {
'fit', 'predict', 'predict_components', 'score',
'get_params', 'set_params', 'sub_models', 'sub_models_', 'sub_model_names',
}


def _build_nice_init(cls):
names = list(cls.sub_model_names)
if len(set(names)) != len(names):
raise ValueError(f"{cls.__name__}.sub_model_names has duplicate entries: {names}")
for name in names:
if not name.isidentifier() or keyword.iskeyword(name):
raise ValueError(f"{cls.__name__}: sub-model name {name!r} is not a valid Python identifier")
if name in _RESERVED_NAMES:
raise ValueError(f"{cls.__name__}: sub-model name {name!r} collides with the CompositeModel API")

params_sig = ", ".join(names)
dict_body = ", ".join(f"{n}={n}" for n in names)
src = (
f"def __init__(self, {params_sig}):\n"
f" CompositeModel.__init__(self, dict({dict_body}))\n"
)
namespace = {'CompositeModel': CompositeModel}
filename = f"<generated __init__ for {cls.__name__}>"
exec(compile(src, filename, 'exec'), namespace)
fn = namespace['__init__']
fn.__qualname__ = f"{cls.__qualname__}.__init__"
fn.__doc__ = (
f"Auto-generated from `sub_model_names` by CompositeModel.__init_subclass__.\n"
f"Equivalent to: super().__init__(dict({dict_body}))"
)
return fn


def _organize_owned_kwargs(kwargs: dict, possible_owners: Collection[str]) -> dict[str, dict]:
possible_owners = set(possible_owners)
kwargs_per_owner = {owner: {} for owner in possible_owners}
for k, v in kwargs.items():
owner, _, subk = k.partition('__')
if subk:
if owner in possible_owners:
kwargs_per_owner[owner].update({subk: v})
else:
warn(f"{k} startswith `{owner}__`, but that's not an option: {possible_owners}. Will be ignored.")
else:
for owner in possible_owners:
kwargs_per_owner[owner].update({k: v})
return kwargs_per_owner
10 changes: 7 additions & 3 deletions foundry/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ def simulate_data(family: str,
n_samples: int,
n_features: int,
predict_params: Optional[Sequence] = None,
random_state: Optional[np.random.RandomState] = None,
**kwargs) -> pd.DataFrame:
"""
:param family: A family-alias, see ``Glm.family_names``.
Expand All @@ -151,6 +152,10 @@ def simulate_data(family: str,
:return: A dataframe with predictors in format ``x_{i}`` and target ``target``. The ground-truth predictor-
coefficients are stored in the ``attrs`` attribute of the dataframe.
"""
random_state = random_state or np.random.randint(1e6)
if not isinstance(random_state, np.random.RandomState):
random_state = np.random.RandomState(random_state)

if isinstance(family, str):
family = family_from_string(family)
if predict_params is None:
Expand All @@ -174,6 +179,7 @@ def simulate_data(family: str,
n_targets=n_targets,
noise=0,
coef=True,
random_state=random_state,
**kwargs
)
# undo squeezing:
Expand Down Expand Up @@ -204,10 +210,8 @@ def simulate_data(family: str,
distribution = family(**family_kwargs)

# set torch random-state:
random_state = kwargs.get('random_state')
with torch.random.fork_rng():
if random_state is not None:
torch.manual_seed(random_state if isinstance(random_state, int) else hash(random_state))
torch.manual_seed(hash(random_state.get_state()[1].tobytes()))

# sample from distribution for target:
out['target'] = distribution.sample().numpy()
Expand Down
Loading