From 3c90460d4f99dd7b7d875159d0a55e6b6614eac2 Mon Sep 17 00:00:00 2001 From: Alexander Fischer Date: Sat, 27 Dec 2025 22:01:09 +0100 Subject: [PATCH 1/5] mvp support for maketables --- doubleml/double_ml.py | 3 +- doubleml/utils/maketables_mixin.py | 195 +++++++ doubleml/utils/tests/test_maketables_mixin.py | 490 ++++++++++++++++++ 3 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 doubleml/utils/maketables_mixin.py create mode 100644 doubleml/utils/tests/test_maketables_mixin.py diff --git a/doubleml/double_ml.py b/doubleml/double_ml.py index 22a066cc..5ae55db8 100644 --- a/doubleml/double_ml.py +++ b/doubleml/double_ml.py @@ -12,6 +12,7 @@ from doubleml.double_ml_framework import DoubleMLCore, DoubleMLFramework from doubleml.double_ml_sampling_mixins import SampleSplittingMixin from doubleml.utils._checks import _check_external_predictions +from doubleml.utils.maketables_mixin import MakeTablesMixin from doubleml.utils._estimation import _aggregate_coefs_and_ses, _rmse, _set_external_predictions, _var_est from doubleml.utils._sensitivity import _compute_sensitivity_bias from doubleml.utils._tune_optuna import OPTUNA_GLOBAL_SETTING_KEYS, TUNE_ML_MODELS_DOC, resolve_optuna_cv @@ -20,7 +21,7 @@ _implemented_data_backends = ["DoubleMLData", "DoubleMLClusterData", "DoubleMLDIDData", "DoubleMLSSMData", "DoubleMLRDDData"] -class DoubleML(SampleSplittingMixin, ABC): +class DoubleML(MakeTablesMixin, SampleSplittingMixin, ABC): """Double Machine Learning.""" def __init__(self, obj_dml_data, n_folds, n_rep, score, draw_sample_splitting, double_sample_splitting=False): diff --git a/doubleml/utils/maketables_mixin.py b/doubleml/utils/maketables_mixin.py new file mode 100644 index 00000000..5eb888bf --- /dev/null +++ b/doubleml/utils/maketables_mixin.py @@ -0,0 +1,195 @@ +""" +MakeTables Mixin for DoubleML Models. + +This module provides a mixin class that adds MakeTables plug-in support to DoubleML models. +The mixin implements the three required attributes for MakeTables compatibility: +- __maketables_coef_table__: Returns coefficient table as DataFrame +- __maketables_stat__: Returns model statistics by key +- __maketables_depvar__: Returns dependent variable name + +This enables zero-coupling integration with MakeTables - DoubleML never imports maketables, +but models automatically work with it when users have maketables installed. +""" + +import numpy as np +import pandas as pd + + +class MakeTablesMixin: + """ + Mixin class for MakeTables plug-in support. + + This mixin adds three attributes that enable DoubleML models to automatically work + with the MakeTables package for creating publication-ready regression tables. + + The plug-in format uses duck typing - MakeTables automatically detects these + attributes when present, without requiring any imports or dependencies. + + Attributes + ---------- + __maketables_coef_table__ : pd.DataFrame (property) + Coefficient table with columns 'b' (estimates), 'se' (standard errors), + 'p' (p-values), 't' (t-statistics), 'ci95l', 'ci95u' (95% CI bounds). + + __maketables_depvar__ : str (property) + Name of the dependent variable. + + __maketables_default_stat_keys__ : list (property) + Default statistics to display in tables. + + Methods + ------- + __maketables_stat__(key) + Return model statistic by key (e.g., 'N' for number of observations). + + Examples + -------- + >>> from doubleml import DoubleMLPLR + >>> # After fitting a DoubleML model + >>> dml_plr.fit() + >>> # Access maketables attributes + >>> coef_table = dml_plr.__maketables_coef_table__ + >>> n_obs = dml_plr.__maketables_stat__('N') + >>> depvar = dml_plr.__maketables_depvar__ + """ + + @property + def __maketables_coef_table__(self) -> pd.DataFrame: + """ + Return coefficient table with all required and optional columns for MakeTables. + + Returns a pandas DataFrame with coefficient estimates, standard errors, p-values, + t-statistics, and 95% confidence intervals. The DataFrame index matches the + treatment variable names from the fitted model. + + Returns + ------- + pd.DataFrame + Coefficient table with columns: + - 'b': coefficient estimates (required) + - 'se': standard errors (required) + - 'p': p-values (required) + - 't': t-statistics (optional) + - 'ci95l': lower 95% confidence interval bound (optional) + - 'ci95u': upper 95% confidence interval bound (optional) + + Notes + ----- + - Returns empty DataFrame with correct columns if model is unfitted or all coefficients are NaN + - Index is set to match the summary table index (treatment variable names) + - Handles edge cases gracefully without raising errors + """ + # Handle unfitted model + if not hasattr(self, "coef") or self.coef is None: + return pd.DataFrame(columns=["b", "se", "t", "p", "ci95l", "ci95u"]) + + # Handle NaN coefficients (model fitted but no valid estimates) + if np.isnan(self.coef).all(): + return pd.DataFrame(columns=["b", "se", "t", "p", "ci95l", "ci95u"]) + + # Get 95% confidence intervals + ci = self.confint(level=0.95) + + # Build coefficient table with required and optional columns + coef_table = pd.DataFrame( + { + "b": self.coef, # Required: coefficient estimates + "se": self.se, # Required: standard errors + "p": self.pval, # Required: p-values + "t": self.t_stat, # Optional: t-statistics + "ci95l": ci.iloc[:, 0], # Optional: lower 95% CI bound + "ci95u": ci.iloc[:, 1], # Optional: upper 95% CI bound + } + ) + + # Set index to match summary table (handles treatment variable names) + if hasattr(self, "summary") and self.summary is not None and len(self.summary) > 0: + coef_table.index = self.summary.index + + return coef_table + + def __maketables_stat__(self, key: str): + """ + Return model statistic by key. + + Parameters + ---------- + key : str + The statistic key to retrieve. Common keys include: + - 'N': number of observations + - 'r2': R-squared (not applicable for DoubleML) + - 'adj_r2': adjusted R-squared (not applicable for DoubleML) + - 'aic': Akaike Information Criterion (not applicable for DoubleML) + - 'bic': Bayesian Information Criterion (not applicable for DoubleML) + - 'll': log-likelihood (not applicable for DoubleML) + + Returns + ------- + float, int, or None + The requested statistic value, or None if not available or not applicable. + + Notes + ----- + DoubleML focuses on causal inference, not prediction, so traditional model fit + statistics like R-squared, AIC, and BIC are not applicable and will return None. + Currently only 'N' (number of observations) is supported. + + Examples + -------- + >>> n_obs = dml_model.__maketables_stat__('N') + >>> r2 = dml_model.__maketables_stat__('r2') # Returns None + """ + stats_map = { + "N": self.n_obs if hasattr(self, "n_obs") else None, + } + return stats_map.get(key, None) + + @property + def __maketables_depvar__(self) -> str: + """ + Return the name of the dependent variable. + + Returns + ------- + str + Name of the dependent (outcome) variable. Defaults to "Y" if not available. + + Notes + ----- + Retrieves the dependent variable name from the DoubleMLData object's y_col attribute. + Falls back to "Y" if the attribute is not available. + + Examples + -------- + >>> depvar = dml_model.__maketables_depvar__ + >>> print(depvar) + 'Y' + """ + if hasattr(self, "_dml_data") and hasattr(self._dml_data, "y_col"): + return self._dml_data.y_col + return "Y" # Fallback + + @property + def __maketables_default_stat_keys__(self) -> list: + """ + Return default statistics to display in MakeTables output. + + Returns + ------- + list + List of statistic keys to display by default. For DoubleML models, + this is ['N'] (number of observations). + + Notes + ----- + This is an optional attribute that helps MakeTables know which statistics + to include in the table by default. Users can override this when calling + ETable() by specifying the model_stats parameter. + + Examples + -------- + >>> default_stats = dml_model.__maketables_default_stat_keys__ + >>> print(default_stats) + ['N'] + """ + return ["N"] diff --git a/doubleml/utils/tests/test_maketables_mixin.py b/doubleml/utils/tests/test_maketables_mixin.py new file mode 100644 index 00000000..870196c8 --- /dev/null +++ b/doubleml/utils/tests/test_maketables_mixin.py @@ -0,0 +1,490 @@ +""" +Tests for MakeTables Mixin. + +This module tests the MakeTables plug-in support for DoubleML models, +verifying that the mixin correctly provides coefficient tables, statistics, +and dependent variable names for use with the MakeTables package. +""" + +import numpy as np +import pandas as pd +import pytest +from sklearn.linear_model import LinearRegression, Lasso + +import doubleml as dml + + +@pytest.fixture(scope="module") +def generate_plr_data(): + """Generate simple data for PLR model testing.""" + np.random.seed(42) + n = 500 + p = 5 + theta = 0.5 + + # Generate simple data + X = np.random.normal(size=(n, p)) + d = 0.5 * X[:, 0] + np.random.normal(size=n) + y = theta * d + X[:, 1] + np.random.normal(size=n) + + df = pd.DataFrame( + np.column_stack((X, y, d)), + columns=[f"X{i+1}" for i in range(p)] + ["Y", "D"] + ) + + return dml.DoubleMLData(df, "Y", "D") + + +@pytest.fixture(scope="module") +def fitted_plr_model(generate_plr_data): + """Create and fit a simple PLR model for testing.""" + ml_l = LinearRegression() + ml_m = LinearRegression() + + dml_plr = dml.DoubleMLPLR( + generate_plr_data, + ml_l, + ml_m, + n_folds=2, + score="partialling out" + ) + dml_plr.fit() + + return dml_plr + + +@pytest.fixture(scope="module") +def unfitted_plr_model(generate_plr_data): + """Create an unfitted PLR model for testing edge cases.""" + ml_l = LinearRegression() + ml_m = LinearRegression() + + dml_plr = dml.DoubleMLPLR( + generate_plr_data, + ml_l, + ml_m, + n_folds=2, + score="partialling out" + ) + + return dml_plr + + +@pytest.fixture(scope="module") +def generate_irm_data(): + """Generate simple data for IRM model testing.""" + np.random.seed(43) + n = 500 + p = 5 + + # Generate simple data with binary treatment + X = np.random.normal(size=(n, p)) + propensity = 1 / (1 + np.exp(-X[:, 0])) + d = (np.random.uniform(size=n) < propensity).astype(float) + y = 0.5 * d + X[:, 1] + np.random.normal(size=n) + + df = pd.DataFrame( + np.column_stack((X, y, d)), + columns=[f"X{i+1}" for i in range(p)] + ["Y", "D"] + ) + + return dml.DoubleMLData(df, "Y", "D") + + +@pytest.fixture(scope="module") +def fitted_irm_model(generate_irm_data): + """Create and fit a simple IRM model for testing.""" + from sklearn.linear_model import LogisticRegression + + ml_g = LinearRegression() + ml_m = LogisticRegression() + + dml_irm = dml.DoubleMLIRM( + generate_irm_data, + ml_g, + ml_m, + n_folds=2, + score="ATE" + ) + dml_irm.fit() + + return dml_irm + + +# ================================================================================== +# Test Coefficient Table Structure +# ================================================================================== + + +@pytest.mark.ci +def test_coef_table_has_required_columns(fitted_plr_model): + """Test that coefficient table has all required columns.""" + coef_table = fitted_plr_model.__maketables_coef_table__ + + # Check DataFrame type + assert isinstance(coef_table, pd.DataFrame) + + # Check required columns exist + assert "b" in coef_table.columns, "Missing required column 'b'" + assert "se" in coef_table.columns, "Missing required column 'se'" + assert "p" in coef_table.columns, "Missing required column 'p'" + + +@pytest.mark.ci +def test_coef_table_has_optional_columns(fitted_plr_model): + """Test that coefficient table has optional columns.""" + coef_table = fitted_plr_model.__maketables_coef_table__ + + # Check optional columns exist + assert "t" in coef_table.columns, "Missing optional column 't'" + assert "ci95l" in coef_table.columns, "Missing optional column 'ci95l'" + assert "ci95u" in coef_table.columns, "Missing optional column 'ci95u'" + + +@pytest.mark.ci +def test_coef_table_index_matches_summary(fitted_plr_model): + """Test that coefficient table index matches summary index.""" + coef_table = fitted_plr_model.__maketables_coef_table__ + summary = fitted_plr_model.summary + + # Index should match treatment variable names + assert list(coef_table.index) == list(summary.index) + + +# ================================================================================== +# Test Coefficient Table Values +# ================================================================================== + + +@pytest.mark.ci +def test_coef_table_values_match_model(fitted_plr_model): + """Test that coefficient table values match the model's estimates.""" + coef_table = fitted_plr_model.__maketables_coef_table__ + + # Check coefficient estimates + np.testing.assert_array_almost_equal( + coef_table["b"].values, + fitted_plr_model.coef, + decimal=10, + err_msg="Coefficient estimates don't match" + ) + + # Check standard errors + np.testing.assert_array_almost_equal( + coef_table["se"].values, + fitted_plr_model.se, + decimal=10, + err_msg="Standard errors don't match" + ) + + # Check t-statistics + np.testing.assert_array_almost_equal( + coef_table["t"].values, + fitted_plr_model.t_stat, + decimal=10, + err_msg="T-statistics don't match" + ) + + # Check p-values + np.testing.assert_array_almost_equal( + coef_table["p"].values, + fitted_plr_model.pval, + decimal=10, + err_msg="P-values don't match" + ) + + +@pytest.mark.ci +def test_coef_table_confidence_intervals(fitted_plr_model): + """Test that confidence intervals match confint() method.""" + coef_table = fitted_plr_model.__maketables_coef_table__ + ci = fitted_plr_model.confint(level=0.95) + + # Check lower CI bound + np.testing.assert_array_almost_equal( + coef_table["ci95l"].values, + ci.iloc[:, 0].values, + decimal=10, + err_msg="Lower CI bounds don't match" + ) + + # Check upper CI bound + np.testing.assert_array_almost_equal( + coef_table["ci95u"].values, + ci.iloc[:, 1].values, + decimal=10, + err_msg="Upper CI bounds don't match" + ) + + +# ================================================================================== +# Test Statistics Method +# ================================================================================== + + +@pytest.mark.ci +def test_stat_method_returns_n_obs(fitted_plr_model): + """Test that __maketables_stat__ returns number of observations for key 'N'.""" + n_obs = fitted_plr_model.__maketables_stat__("N") + + assert n_obs is not None, "Should return number of observations" + assert n_obs == fitted_plr_model.n_obs, "N should match model's n_obs" + assert isinstance(n_obs, (int, np.integer)), "N should be an integer" + + +@pytest.mark.ci +def test_stat_method_unknown_keys_return_none(fitted_plr_model): + """Test that __maketables_stat__ returns None for unknown keys.""" + # Unknown key should return None + assert fitted_plr_model.__maketables_stat__("unknown_key") is None + + # Empty string should return None + assert fitted_plr_model.__maketables_stat__("") is None + + +@pytest.mark.ci +def test_stat_method_traditional_stats_return_none(fitted_plr_model): + """Test that traditional stats (r2, aic, bic) return None for causal models.""" + # R-squared not applicable for causal inference + assert fitted_plr_model.__maketables_stat__("r2") is None + assert fitted_plr_model.__maketables_stat__("adj_r2") is None + + # Information criteria not applicable + assert fitted_plr_model.__maketables_stat__("aic") is None + assert fitted_plr_model.__maketables_stat__("bic") is None + + # Log-likelihood not applicable + assert fitted_plr_model.__maketables_stat__("ll") is None + + +# ================================================================================== +# Test Dependent Variable +# ================================================================================== + + +@pytest.mark.ci +def test_depvar_returns_string(fitted_plr_model): + """Test that __maketables_depvar__ returns a string.""" + depvar = fitted_plr_model.__maketables_depvar__ + + assert isinstance(depvar, str), "Dependent variable name should be a string" + + +@pytest.mark.ci +def test_depvar_matches_data(fitted_plr_model): + """Test that __maketables_depvar__ matches the actual dependent variable.""" + depvar = fitted_plr_model.__maketables_depvar__ + + assert depvar == "Y", "Dependent variable should be 'Y'" + assert depvar == fitted_plr_model._dml_data.y_col, "Should match data's y_col" + + +# ================================================================================== +# Test Default Statistics Keys +# ================================================================================== + + +@pytest.mark.ci +def test_default_stat_keys_returns_list(fitted_plr_model): + """Test that __maketables_default_stat_keys__ returns a list.""" + default_keys = fitted_plr_model.__maketables_default_stat_keys__ + + assert isinstance(default_keys, list), "Default stat keys should be a list" + + +@pytest.mark.ci +def test_default_stat_keys_contains_n(fitted_plr_model): + """Test that default statistics include 'N'.""" + default_keys = fitted_plr_model.__maketables_default_stat_keys__ + + assert "N" in default_keys, "Default statistics should include 'N'" + + +# ================================================================================== +# Test Multiple Model Types +# ================================================================================== + + +@pytest.mark.ci +def test_maketables_works_with_irm_model(fitted_irm_model): + """Test that maketables mixin works with IRM models.""" + # Should have coefficient table + coef_table = fitted_irm_model.__maketables_coef_table__ + assert isinstance(coef_table, pd.DataFrame) + assert "b" in coef_table.columns + assert "se" in coef_table.columns + assert "p" in coef_table.columns + + # Should return N statistic + assert fitted_irm_model.__maketables_stat__("N") == fitted_irm_model.n_obs + + # Should return depvar + assert fitted_irm_model.__maketables_depvar__ == "Y" + + +@pytest.mark.ci +def test_maketables_works_with_pliv_model(generate_plr_data): + """Test that maketables mixin works with PLIV models.""" + from doubleml.plm.datasets import make_pliv_CHS2015 + + # Generate IV data + np.random.seed(44) + data = make_pliv_CHS2015(n_obs=500, dim_x=5, alpha=0.5, dim_z=1, return_type=pd.DataFrame) + + x_cols = [col for col in data.columns if col.startswith("X")] + dml_data = dml.DoubleMLData(data, "y", "d", x_cols, z_cols="Z1") + + ml_l = LinearRegression() + ml_m = LinearRegression() + ml_r = LinearRegression() + + dml_pliv = dml.DoubleMLPLIV(dml_data, ml_l, ml_m, ml_r, n_folds=2) + dml_pliv.fit() + + # Should have coefficient table + coef_table = dml_pliv.__maketables_coef_table__ + assert isinstance(coef_table, pd.DataFrame) + assert "b" in coef_table.columns + + # Should return N statistic + assert dml_pliv.__maketables_stat__("N") == dml_pliv.n_obs + + # Should return depvar + assert dml_pliv.__maketables_depvar__ == "y" + + +# ================================================================================== +# Test Edge Cases +# ================================================================================== + + +@pytest.mark.ci +def test_unfitted_model_returns_empty_dataframe(unfitted_plr_model): + """Test that unfitted model returns empty DataFrame with correct columns.""" + coef_table = unfitted_plr_model.__maketables_coef_table__ + + assert isinstance(coef_table, pd.DataFrame), "Should return DataFrame" + assert len(coef_table) == 0, "Should be empty for unfitted model" + + # Should still have correct columns + expected_columns = ["b", "se", "t", "p", "ci95l", "ci95u"] + assert list(coef_table.columns) == expected_columns + + +@pytest.mark.ci +def test_unfitted_model_stat_returns_n(unfitted_plr_model): + """Test that unfitted model can still return N statistic.""" + n_obs = unfitted_plr_model.__maketables_stat__("N") + + # Should still have n_obs even if not fitted + assert n_obs is not None + assert n_obs == unfitted_plr_model.n_obs + + +@pytest.mark.ci +def test_unfitted_model_depvar_works(unfitted_plr_model): + """Test that unfitted model can return dependent variable name.""" + depvar = unfitted_plr_model.__maketables_depvar__ + + assert depvar == "Y" + + +@pytest.mark.ci +def test_multi_treatment_model(): + """Test that maketables works with multiple treatment variables.""" + np.random.seed(45) + n = 500 + p = 5 + + # Generate data with 2 treatments + X = np.random.normal(size=(n, p)) + D1 = 0.5 * X[:, 0] + np.random.normal(size=n) + D2 = 0.3 * X[:, 1] + np.random.normal(size=n) + Y = 0.5 * D1 + 0.7 * D2 + X[:, 2] + np.random.normal(size=n) + + df = pd.DataFrame( + np.column_stack((X, Y, D1, D2)), + columns=[f"X{i+1}" for i in range(p)] + ["Y", "D1", "D2"] + ) + + dml_data = dml.DoubleMLData(df, "Y", ["D1", "D2"]) + + ml_l = LinearRegression() + ml_m = LinearRegression() + + dml_plr = dml.DoubleMLPLR(dml_data, ml_l, ml_m, n_folds=2, score="partialling out") + dml_plr.fit() + + # Coefficient table should have 2 rows (one per treatment) + coef_table = dml_plr.__maketables_coef_table__ + assert len(coef_table) == 2, "Should have 2 rows for 2 treatments" + assert list(coef_table.index) == ["D1", "D2"], "Index should be treatment names" + + +# ================================================================================== +# Test Integration (Optional - requires maketables) +# ================================================================================== + + +@pytest.mark.ci +def test_integration_with_maketables_if_available(fitted_plr_model): + """Test integration with maketables package if it's installed.""" + pytest.importorskip("maketables", reason="maketables not installed") + + from maketables import ETable + + # Should be able to create a table + try: + table = ETable([fitted_plr_model]) + assert table is not None + + # Should be able to render + text_output = table.render("txt") + assert isinstance(text_output, str) + assert len(text_output) > 0 + + except Exception as e: + pytest.fail(f"MakeTables integration failed: {str(e)}") + + +# ================================================================================== +# Test Comparison with Summary +# ================================================================================== + + +@pytest.mark.ci +def test_coef_table_consistent_with_summary(fitted_plr_model): + """Test that coefficient table is consistent with summary property.""" + coef_table = fitted_plr_model.__maketables_coef_table__ + summary = fitted_plr_model.summary + + # Same index + assert list(coef_table.index) == list(summary.index) + + # Coefficients match + np.testing.assert_array_almost_equal( + coef_table["b"].values, + summary["coef"].values, + decimal=10 + ) + + # Standard errors match + np.testing.assert_array_almost_equal( + coef_table["se"].values, + summary["std err"].values, + decimal=10 + ) + + # T-statistics match + np.testing.assert_array_almost_equal( + coef_table["t"].values, + summary["t"].values, + decimal=10 + ) + + # P-values match + np.testing.assert_array_almost_equal( + coef_table["p"].values, + summary["P>|t|"].values, + decimal=10 + ) From 34a01dc367ff5e814064a01681ba3f5757a59feb Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:43:22 -0800 Subject: [PATCH 2/5] feat: validate MakeTables integration and simplify result extraction --- doubleml/double_ml.py | 2 +- doubleml/utils/maketables_mixin.py | 184 ++---- doubleml/utils/tests/test_maketables_mixin.py | 577 +++--------------- pyproject.toml | 1 + 4 files changed, 137 insertions(+), 627 deletions(-) diff --git a/doubleml/double_ml.py b/doubleml/double_ml.py index 5ae55db8..5353069d 100644 --- a/doubleml/double_ml.py +++ b/doubleml/double_ml.py @@ -12,11 +12,11 @@ from doubleml.double_ml_framework import DoubleMLCore, DoubleMLFramework from doubleml.double_ml_sampling_mixins import SampleSplittingMixin from doubleml.utils._checks import _check_external_predictions -from doubleml.utils.maketables_mixin import MakeTablesMixin from doubleml.utils._estimation import _aggregate_coefs_and_ses, _rmse, _set_external_predictions, _var_est from doubleml.utils._sensitivity import _compute_sensitivity_bias from doubleml.utils._tune_optuna import OPTUNA_GLOBAL_SETTING_KEYS, TUNE_ML_MODELS_DOC, resolve_optuna_cv from doubleml.utils.gain_statistics import gain_statistics +from doubleml.utils.maketables_mixin import MakeTablesMixin _implemented_data_backends = ["DoubleMLData", "DoubleMLClusterData", "DoubleMLDIDData", "DoubleMLSSMData", "DoubleMLRDDData"] diff --git a/doubleml/utils/maketables_mixin.py b/doubleml/utils/maketables_mixin.py index 5eb888bf..f4dafeab 100644 --- a/doubleml/utils/maketables_mixin.py +++ b/doubleml/utils/maketables_mixin.py @@ -1,195 +1,81 @@ -""" -MakeTables Mixin for DoubleML Models. +"""MakeTables plug-in support for models inheriting from DoubleML.""" -This module provides a mixin class that adds MakeTables plug-in support to DoubleML models. -The mixin implements the three required attributes for MakeTables compatibility: -- __maketables_coef_table__: Returns coefficient table as DataFrame -- __maketables_stat__: Returns model statistics by key -- __maketables_depvar__: Returns dependent variable name - -This enables zero-coupling integration with MakeTables - DoubleML never imports maketables, -but models automatically work with it when users have maketables installed. -""" - -import numpy as np import pandas as pd class MakeTablesMixin: - """ - Mixin class for MakeTables plug-in support. - - This mixin adds three attributes that enable DoubleML models to automatically work - with the MakeTables package for creating publication-ready regression tables. - - The plug-in format uses duck typing - MakeTables automatically detects these - attributes when present, without requiring any imports or dependencies. - - Attributes - ---------- - __maketables_coef_table__ : pd.DataFrame (property) - Coefficient table with columns 'b' (estimates), 'se' (standard errors), - 'p' (p-values), 't' (t-statistics), 'ci95l', 'ci95u' (95% CI bounds). - - __maketables_depvar__ : str (property) - Name of the dependent variable. - - __maketables_default_stat_keys__ : list (property) - Default statistics to display in tables. + """Expose fitted DoubleML results through the MakeTables plug-in protocol. - Methods - ------- - __maketables_stat__(key) - Return model statistic by key (e.g., 'N' for number of observations). - - Examples - -------- - >>> from doubleml import DoubleMLPLR - >>> # After fitting a DoubleML model - >>> dml_plr.fit() - >>> # Access maketables attributes - >>> coef_table = dml_plr.__maketables_coef_table__ - >>> n_obs = dml_plr.__maketables_stat__('N') - >>> depvar = dml_plr.__maketables_depvar__ + MakeTables detects these attributes without DoubleML importing it. Users + install ``maketables`` separately to create tables from fitted models. """ @property def __maketables_coef_table__(self) -> pd.DataFrame: - """ - Return coefficient table with all required and optional columns for MakeTables. - - Returns a pandas DataFrame with coefficient estimates, standard errors, p-values, - t-statistics, and 95% confidence intervals. The DataFrame index matches the - treatment variable names from the fitted model. + """Return estimates and pointwise inference for MakeTables. Returns ------- pd.DataFrame - Coefficient table with columns: - - 'b': coefficient estimates (required) - - 'se': standard errors (required) - - 'p': p-values (required) - - 't': t-statistics (optional) - - 'ci95l': lower 95% confidence interval bound (optional) - - 'ci95u': upper 95% confidence interval bound (optional) + Treatment-indexed estimates (``b``), standard errors (``se``), + t-statistics (``t``), p-values (``p``), and pointwise 95% confidence + interval bounds (``ci95l``, ``ci95u``). Confidence intervals use + DoubleML's aggregation across repeated sample splits. Notes ----- - - Returns empty DataFrame with correct columns if model is unfitted or all coefficients are NaN - - Index is set to match the summary table index (treatment variable names) - - Handles edge cases gracefully without raising errors + Before fitting, returns an empty table with the same columns, consistent + with ``DoubleML.summary``. No coefficient estimates are displayed. """ - # Handle unfitted model - if not hasattr(self, "coef") or self.coef is None: - return pd.DataFrame(columns=["b", "se", "t", "p", "ci95l", "ci95u"]) - - # Handle NaN coefficients (model fitted but no valid estimates) - if np.isnan(self.coef).all(): + if self.framework is None: return pd.DataFrame(columns=["b", "se", "t", "p", "ci95l", "ci95u"]) - # Get 95% confidence intervals - ci = self.confint(level=0.95) - - # Build coefficient table with required and optional columns - coef_table = pd.DataFrame( + ci = self.confint(joint=False, level=0.95) + return pd.DataFrame( { - "b": self.coef, # Required: coefficient estimates - "se": self.se, # Required: standard errors - "p": self.pval, # Required: p-values - "t": self.t_stat, # Optional: t-statistics - "ci95l": ci.iloc[:, 0], # Optional: lower 95% CI bound - "ci95u": ci.iloc[:, 1], # Optional: upper 95% CI bound - } + "b": self.coef, + "se": self.se, + "t": self.t_stat, + "p": self.pval, + "ci95l": ci.iloc[:, 0], + "ci95u": ci.iloc[:, 1], + }, + index=ci.index, ) - # Set index to match summary table (handles treatment variable names) - if hasattr(self, "summary") and self.summary is not None and len(self.summary) > 0: - coef_table.index = self.summary.index - - return coef_table - - def __maketables_stat__(self, key: str): - """ - Return model statistic by key. + def __maketables_stat__(self, key: str) -> int | None: + """Return a model statistic for MakeTables. Parameters ---------- key : str - The statistic key to retrieve. Common keys include: - - 'N': number of observations - - 'r2': R-squared (not applicable for DoubleML) - - 'adj_r2': adjusted R-squared (not applicable for DoubleML) - - 'aic': Akaike Information Criterion (not applicable for DoubleML) - - 'bic': Bayesian Information Criterion (not applicable for DoubleML) - - 'll': log-likelihood (not applicable for DoubleML) + Statistic identifier. Only ``'N'`` (sample size) is supported. Returns ------- - float, int, or None - The requested statistic value, or None if not available or not applicable. - - Notes - ----- - DoubleML focuses on causal inference, not prediction, so traditional model fit - statistics like R-squared, AIC, and BIC are not applicable and will return None. - Currently only 'N' (number of observations) is supported. - - Examples - -------- - >>> n_obs = dml_model.__maketables_stat__('N') - >>> r2 = dml_model.__maketables_stat__('r2') # Returns None + int or None + Number of observations, or ``None`` for an unsupported statistic. """ - stats_map = { - "N": self.n_obs if hasattr(self, "n_obs") else None, - } - return stats_map.get(key, None) + return self.n_obs if key == "N" else None @property def __maketables_depvar__(self) -> str: - """ - Return the name of the dependent variable. + """Return the outcome variable name. Returns ------- str - Name of the dependent (outcome) variable. Defaults to "Y" if not available. - - Notes - ----- - Retrieves the dependent variable name from the DoubleMLData object's y_col attribute. - Falls back to "Y" if the attribute is not available. - - Examples - -------- - >>> depvar = dml_model.__maketables_depvar__ - >>> print(depvar) - 'Y' + Outcome column of the model's data object. """ - if hasattr(self, "_dml_data") and hasattr(self._dml_data, "y_col"): - return self._dml_data.y_col - return "Y" # Fallback + return self._dml_data.y_col @property - def __maketables_default_stat_keys__(self) -> list: - """ - Return default statistics to display in MakeTables output. + def __maketables_default_stat_keys__(self) -> list[str]: + """Return the default model statistics for MakeTables. Returns ------- - list - List of statistic keys to display by default. For DoubleML models, - this is ['N'] (number of observations). - - Notes - ----- - This is an optional attribute that helps MakeTables know which statistics - to include in the table by default. Users can override this when calling - ETable() by specifying the model_stats parameter. - - Examples - -------- - >>> default_stats = dml_model.__maketables_default_stat_keys__ - >>> print(default_stats) - ['N'] + list[str] + ``['N']``. Users can override this through ``ETable(model_stats=...)``. """ return ["N"] diff --git a/doubleml/utils/tests/test_maketables_mixin.py b/doubleml/utils/tests/test_maketables_mixin.py index 870196c8..eab22a19 100644 --- a/doubleml/utils/tests/test_maketables_mixin.py +++ b/doubleml/utils/tests/test_maketables_mixin.py @@ -1,490 +1,113 @@ -""" -Tests for MakeTables Mixin. - -This module tests the MakeTables plug-in support for DoubleML models, -verifying that the mixin correctly provides coefficient tables, statistics, -and dependent variable names for use with the MakeTables package. -""" +"""Test MakeTables integration against fitted DoubleML estimates and rendered tables.""" import numpy as np import pandas as pd import pytest -from sklearn.linear_model import LinearRegression, Lasso +from maketables import ETable +from maketables.extractors import get_extractor +from sklearn.linear_model import LinearRegression, LogisticRegression import doubleml as dml -@pytest.fixture(scope="module") -def generate_plr_data(): - """Generate simple data for PLR model testing.""" - np.random.seed(42) - n = 500 - p = 5 - theta = 0.5 - - # Generate simple data - X = np.random.normal(size=(n, p)) - d = 0.5 * X[:, 0] + np.random.normal(size=n) - y = theta * d + X[:, 1] + np.random.normal(size=n) - - df = pd.DataFrame( - np.column_stack((X, y, d)), - columns=[f"X{i+1}" for i in range(p)] + ["Y", "D"] - ) - - return dml.DoubleMLData(df, "Y", "D") - - -@pytest.fixture(scope="module") -def fitted_plr_model(generate_plr_data): - """Create and fit a simple PLR model for testing.""" - ml_l = LinearRegression() - ml_m = LinearRegression() - - dml_plr = dml.DoubleMLPLR( - generate_plr_data, - ml_l, - ml_m, - n_folds=2, - score="partialling out" - ) - dml_plr.fit() - - return dml_plr - - -@pytest.fixture(scope="module") -def unfitted_plr_model(generate_plr_data): - """Create an unfitted PLR model for testing edge cases.""" - ml_l = LinearRegression() - ml_m = LinearRegression() - - dml_plr = dml.DoubleMLPLR( - generate_plr_data, - ml_l, - ml_m, - n_folds=2, - score="partialling out" - ) - - return dml_plr - - -@pytest.fixture(scope="module") -def generate_irm_data(): - """Generate simple data for IRM model testing.""" - np.random.seed(43) - n = 500 - p = 5 - - # Generate simple data with binary treatment - X = np.random.normal(size=(n, p)) - propensity = 1 / (1 + np.exp(-X[:, 0])) - d = (np.random.uniform(size=n) < propensity).astype(float) - y = 0.5 * d + X[:, 1] + np.random.normal(size=n) +@pytest.fixture(scope="module", params=["plr", "multi", "irm", "pliv", "cluster"]) +def model_kind(request): + return request.param - df = pd.DataFrame( - np.column_stack((X, y, d)), - columns=[f"X{i+1}" for i in range(p)] + ["Y", "D"] - ) - return dml.DoubleMLData(df, "Y", "D") +@pytest.fixture(scope="module", params=[1, 3]) +def n_rep(request): + return request.param @pytest.fixture(scope="module") -def fitted_irm_model(generate_irm_data): - """Create and fit a simple IRM model for testing.""" - from sklearn.linear_model import LogisticRegression - - ml_g = LinearRegression() - ml_m = LogisticRegression() - - dml_irm = dml.DoubleMLIRM( - generate_irm_data, - ml_g, - ml_m, - n_folds=2, - score="ATE" - ) - dml_irm.fit() - - return dml_irm - - -# ================================================================================== -# Test Coefficient Table Structure -# ================================================================================== - - -@pytest.mark.ci -def test_coef_table_has_required_columns(fitted_plr_model): - """Test that coefficient table has all required columns.""" - coef_table = fitted_plr_model.__maketables_coef_table__ - - # Check DataFrame type - assert isinstance(coef_table, pd.DataFrame) - - # Check required columns exist - assert "b" in coef_table.columns, "Missing required column 'b'" - assert "se" in coef_table.columns, "Missing required column 'se'" - assert "p" in coef_table.columns, "Missing required column 'p'" - - -@pytest.mark.ci -def test_coef_table_has_optional_columns(fitted_plr_model): - """Test that coefficient table has optional columns.""" - coef_table = fitted_plr_model.__maketables_coef_table__ - - # Check optional columns exist - assert "t" in coef_table.columns, "Missing optional column 't'" - assert "ci95l" in coef_table.columns, "Missing optional column 'ci95l'" - assert "ci95u" in coef_table.columns, "Missing optional column 'ci95u'" - - -@pytest.mark.ci -def test_coef_table_index_matches_summary(fitted_plr_model): - """Test that coefficient table index matches summary index.""" - coef_table = fitted_plr_model.__maketables_coef_table__ - summary = fitted_plr_model.summary - - # Index should match treatment variable names - assert list(coef_table.index) == list(summary.index) - - -# ================================================================================== -# Test Coefficient Table Values -# ================================================================================== - - -@pytest.mark.ci -def test_coef_table_values_match_model(fitted_plr_model): - """Test that coefficient table values match the model's estimates.""" - coef_table = fitted_plr_model.__maketables_coef_table__ - - # Check coefficient estimates - np.testing.assert_array_almost_equal( - coef_table["b"].values, - fitted_plr_model.coef, - decimal=10, - err_msg="Coefficient estimates don't match" - ) - - # Check standard errors - np.testing.assert_array_almost_equal( - coef_table["se"].values, - fitted_plr_model.se, - decimal=10, - err_msg="Standard errors don't match" - ) - - # Check t-statistics - np.testing.assert_array_almost_equal( - coef_table["t"].values, - fitted_plr_model.t_stat, - decimal=10, - err_msg="T-statistics don't match" - ) - - # Check p-values - np.testing.assert_array_almost_equal( - coef_table["p"].values, - fitted_plr_model.pval, - decimal=10, - err_msg="P-values don't match" - ) - - -@pytest.mark.ci -def test_coef_table_confidence_intervals(fitted_plr_model): - """Test that confidence intervals match confint() method.""" - coef_table = fitted_plr_model.__maketables_coef_table__ - ci = fitted_plr_model.confint(level=0.95) - - # Check lower CI bound - np.testing.assert_array_almost_equal( - coef_table["ci95l"].values, - ci.iloc[:, 0].values, - decimal=10, - err_msg="Lower CI bounds don't match" - ) - - # Check upper CI bound - np.testing.assert_array_almost_equal( - coef_table["ci95u"].values, - ci.iloc[:, 1].values, - decimal=10, - err_msg="Upper CI bounds don't match" - ) - - -# ================================================================================== -# Test Statistics Method -# ================================================================================== - - -@pytest.mark.ci -def test_stat_method_returns_n_obs(fitted_plr_model): - """Test that __maketables_stat__ returns number of observations for key 'N'.""" - n_obs = fitted_plr_model.__maketables_stat__("N") - - assert n_obs is not None, "Should return number of observations" - assert n_obs == fitted_plr_model.n_obs, "N should match model's n_obs" - assert isinstance(n_obs, (int, np.integer)), "N should be an integer" - - -@pytest.mark.ci -def test_stat_method_unknown_keys_return_none(fitted_plr_model): - """Test that __maketables_stat__ returns None for unknown keys.""" - # Unknown key should return None - assert fitted_plr_model.__maketables_stat__("unknown_key") is None - - # Empty string should return None - assert fitted_plr_model.__maketables_stat__("") is None - - -@pytest.mark.ci -def test_stat_method_traditional_stats_return_none(fitted_plr_model): - """Test that traditional stats (r2, aic, bic) return None for causal models.""" - # R-squared not applicable for causal inference - assert fitted_plr_model.__maketables_stat__("r2") is None - assert fitted_plr_model.__maketables_stat__("adj_r2") is None - - # Information criteria not applicable - assert fitted_plr_model.__maketables_stat__("aic") is None - assert fitted_plr_model.__maketables_stat__("bic") is None - - # Log-likelihood not applicable - assert fitted_plr_model.__maketables_stat__("ll") is None - - -# ================================================================================== -# Test Dependent Variable -# ================================================================================== - - -@pytest.mark.ci -def test_depvar_returns_string(fitted_plr_model): - """Test that __maketables_depvar__ returns a string.""" - depvar = fitted_plr_model.__maketables_depvar__ - - assert isinstance(depvar, str), "Dependent variable name should be a string" - - -@pytest.mark.ci -def test_depvar_matches_data(fitted_plr_model): - """Test that __maketables_depvar__ matches the actual dependent variable.""" - depvar = fitted_plr_model.__maketables_depvar__ - - assert depvar == "Y", "Dependent variable should be 'Y'" - assert depvar == fitted_plr_model._dml_data.y_col, "Should match data's y_col" - - -# ================================================================================== -# Test Default Statistics Keys -# ================================================================================== - - -@pytest.mark.ci -def test_default_stat_keys_returns_list(fitted_plr_model): - """Test that __maketables_default_stat_keys__ returns a list.""" - default_keys = fitted_plr_model.__maketables_default_stat_keys__ - - assert isinstance(default_keys, list), "Default stat keys should be a list" - - -@pytest.mark.ci -def test_default_stat_keys_contains_n(fitted_plr_model): - """Test that default statistics include 'N'.""" - default_keys = fitted_plr_model.__maketables_default_stat_keys__ - - assert "N" in default_keys, "Default statistics should include 'N'" - - -# ================================================================================== -# Test Multiple Model Types -# ================================================================================== - - -@pytest.mark.ci -def test_maketables_works_with_irm_model(fitted_irm_model): - """Test that maketables mixin works with IRM models.""" - # Should have coefficient table - coef_table = fitted_irm_model.__maketables_coef_table__ - assert isinstance(coef_table, pd.DataFrame) - assert "b" in coef_table.columns - assert "se" in coef_table.columns - assert "p" in coef_table.columns - - # Should return N statistic - assert fitted_irm_model.__maketables_stat__("N") == fitted_irm_model.n_obs - - # Should return depvar - assert fitted_irm_model.__maketables_depvar__ == "Y" - - -@pytest.mark.ci -def test_maketables_works_with_pliv_model(generate_plr_data): - """Test that maketables mixin works with PLIV models.""" - from doubleml.plm.datasets import make_pliv_CHS2015 - - # Generate IV data - np.random.seed(44) - data = make_pliv_CHS2015(n_obs=500, dim_x=5, alpha=0.5, dim_z=1, return_type=pd.DataFrame) - - x_cols = [col for col in data.columns if col.startswith("X")] - dml_data = dml.DoubleMLData(data, "y", "d", x_cols, z_cols="Z1") - - ml_l = LinearRegression() - ml_m = LinearRegression() - ml_r = LinearRegression() - - dml_pliv = dml.DoubleMLPLIV(dml_data, ml_l, ml_m, ml_r, n_folds=2) - dml_pliv.fit() - - # Should have coefficient table - coef_table = dml_pliv.__maketables_coef_table__ - assert isinstance(coef_table, pd.DataFrame) - assert "b" in coef_table.columns - - # Should return N statistic - assert dml_pliv.__maketables_stat__("N") == dml_pliv.n_obs - - # Should return depvar - assert dml_pliv.__maketables_depvar__ == "y" - - -# ================================================================================== -# Test Edge Cases -# ================================================================================== - - -@pytest.mark.ci -def test_unfitted_model_returns_empty_dataframe(unfitted_plr_model): - """Test that unfitted model returns empty DataFrame with correct columns.""" - coef_table = unfitted_plr_model.__maketables_coef_table__ - - assert isinstance(coef_table, pd.DataFrame), "Should return DataFrame" - assert len(coef_table) == 0, "Should be empty for unfitted model" - - # Should still have correct columns - expected_columns = ["b", "se", "t", "p", "ci95l", "ci95u"] - assert list(coef_table.columns) == expected_columns - - -@pytest.mark.ci -def test_unfitted_model_stat_returns_n(unfitted_plr_model): - """Test that unfitted model can still return N statistic.""" - n_obs = unfitted_plr_model.__maketables_stat__("N") - - # Should still have n_obs even if not fitted - assert n_obs is not None - assert n_obs == unfitted_plr_model.n_obs - - -@pytest.mark.ci -def test_unfitted_model_depvar_works(unfitted_plr_model): - """Test that unfitted model can return dependent variable name.""" - depvar = unfitted_plr_model.__maketables_depvar__ - - assert depvar == "Y" - - -@pytest.mark.ci -def test_multi_treatment_model(): - """Test that maketables works with multiple treatment variables.""" - np.random.seed(45) - n = 500 - p = 5 - - # Generate data with 2 treatments - X = np.random.normal(size=(n, p)) - D1 = 0.5 * X[:, 0] + np.random.normal(size=n) - D2 = 0.3 * X[:, 1] + np.random.normal(size=n) - Y = 0.5 * D1 + 0.7 * D2 + X[:, 2] + np.random.normal(size=n) - - df = pd.DataFrame( - np.column_stack((X, Y, D1, D2)), - columns=[f"X{i+1}" for i in range(p)] + ["Y", "D1", "D2"] - ) - - dml_data = dml.DoubleMLData(df, "Y", ["D1", "D2"]) - - ml_l = LinearRegression() - ml_m = LinearRegression() - - dml_plr = dml.DoubleMLPLR(dml_data, ml_l, ml_m, n_folds=2, score="partialling out") - dml_plr.fit() - - # Coefficient table should have 2 rows (one per treatment) - coef_table = dml_plr.__maketables_coef_table__ - assert len(coef_table) == 2, "Should have 2 rows for 2 treatments" - assert list(coef_table.index) == ["D1", "D2"], "Index should be treatment names" - - -# ================================================================================== -# Test Integration (Optional - requires maketables) -# ================================================================================== - - -@pytest.mark.ci -def test_integration_with_maketables_if_available(fitted_plr_model): - """Test integration with maketables package if it's installed.""" - pytest.importorskip("maketables", reason="maketables not installed") - - from maketables import ETable - - # Should be able to create a table - try: - table = ETable([fitted_plr_model]) - assert table is not None - - # Should be able to render - text_output = table.render("txt") - assert isinstance(text_output, str) - assert len(text_output) > 0 - - except Exception as e: - pytest.fail(f"MakeTables integration failed: {str(e)}") - - -# ================================================================================== -# Test Comparison with Summary -# ================================================================================== - - -@pytest.mark.ci -def test_coef_table_consistent_with_summary(fitted_plr_model): - """Test that coefficient table is consistent with summary property.""" - coef_table = fitted_plr_model.__maketables_coef_table__ - summary = fitted_plr_model.summary - - # Same index - assert list(coef_table.index) == list(summary.index) - - # Coefficients match - np.testing.assert_array_almost_equal( - coef_table["b"].values, - summary["coef"].values, - decimal=10 - ) - - # Standard errors match - np.testing.assert_array_almost_equal( - coef_table["se"].values, - summary["std err"].values, - decimal=10 - ) - - # T-statistics match - np.testing.assert_array_almost_equal( - coef_table["t"].values, - summary["t"].values, - decimal=10 - ) - - # P-values match - np.testing.assert_array_almost_equal( - coef_table["p"].values, - summary["P>|t|"].values, - decimal=10 - ) +def fitted_model(model_kind, n_rep): + np.random.seed(3141) + n_obs = 200 + x = np.random.normal(size=(n_obs, 3)) + z = np.random.normal(size=n_obs) + d = x[:, 0] + z + np.random.normal(size=n_obs) + if model_kind == "irm": + d = (d > 0).astype(float) + second = x[:, 1] + np.random.normal(size=n_obs) + y = 0.5 * d + 0.3 * second + x[:, 2] + np.random.normal(size=n_obs) + frame = pd.DataFrame(x, columns=["x1", "x2", "x3"]) + frame["outcome"] = y + frame["treatment_z"] = d + frame["treatment_a"] = second + frame["instrument"] = z + d_cols = ["treatment_z", "treatment_a"] if model_kind == "multi" else ["treatment_z"] + kwargs = {"y_col": "outcome", "d_cols": d_cols, "x_cols": ["x1", "x2", "x3"]} + if model_kind == "cluster": + frame["cluster"] = np.repeat(np.arange(40), 5) + data = dml.DoubleMLData(frame, cluster_cols="cluster", **kwargs) + else: + data = dml.DoubleMLData(frame, z_cols="instrument" if model_kind == "pliv" else None, **kwargs) + if model_kind == "irm": + model = dml.DoubleMLIRM(data, LinearRegression(), LogisticRegression(), n_folds=2, n_rep=n_rep) + elif model_kind == "pliv": + model = dml.DoubleMLPLIV(data, LinearRegression(), LinearRegression(), LinearRegression(), n_folds=2, n_rep=n_rep) + else: + model = dml.DoubleMLPLR(data, LinearRegression(), LinearRegression(), n_folds=2, n_rep=n_rep) + return model.fit() + + +@pytest.mark.ci +def test_maketables_estimates(fitted_model): + """The detected extractor preserves estimates, inference, and treatment order.""" + model = fitted_model + before = model.summary.copy(deep=True) + table = get_extractor(model).coef_table(model) + expected = before.rename(columns={"coef": "b", "std err": "se", "P>|t|": "p", "2.5 %": "ci95l", "97.5 %": "ci95u"}) + expected.index.name = "Coefficient" + pd.testing.assert_frame_equal(table[expected.columns], expected) + pd.testing.assert_frame_equal(model.summary, before) + table.iloc[0, 0] = 999 + pd.testing.assert_frame_equal(model.summary, before) + + +@pytest.mark.ci +def test_maketables_metadata(fitted_model): + """MakeTables receives the outcome name, sample size, and supported defaults.""" + extractor = get_extractor(fitted_model) + assert extractor.depvar(fitted_model) == "outcome" + assert extractor.stat(fitted_model, "N") == 200 + assert extractor.default_stat_keys(fitted_model) == ["N"] + assert extractor.stat(fitted_model, "r2") is None + assert extractor.stat(fitted_model, "unknown") is None + + +@pytest.mark.ci +@pytest.mark.parametrize("output_format", ["tex", "html"]) +def test_maketables_render(fitted_model, output_format): + """Actual exported tables contain estimates and pointwise confidence intervals.""" + model = fitted_model + table = ETable(model, coef_fmt="b:.6f (se:.6f) [ci95l:.6f, ci95u:.6f]", labels={"treatment_z": "Treatment"}) + output = table.make(output_format) + assert "Treatment" in output + assert "outcome" in output + assert "200" in output + for value in [model.coef[0], model.se[0], *model.confint().iloc[0]]: + assert f"{value:.6f}" in output + + +@pytest.mark.ci +def test_maketables_multiple_models(fitted_model): + """The same estimate is rendered in each requested model column.""" + output = ETable([fitted_model, fitted_model], coef_fmt="b:.6f").make("tex") + assert output.count(f"{fitted_model.coef[0]:.6f}") == 2 + + +@pytest.mark.ci +def test_maketables_before_fit(): + """An unfitted model produces no estimates, matching DoubleML.summary.""" + np.random.seed(3141) + data = dml.DoubleMLData.from_arrays(np.random.normal(size=(20, 2)), np.arange(20.0), np.arange(20.0)) + model = dml.DoubleMLPLR(data, LinearRegression(), LinearRegression(), n_folds=2) + table = model.__maketables_coef_table__ + assert table.empty + assert list(table.columns) == ["b", "se", "t", "p", "ci95l", "ci95u"] + output = ETable(model).make("tex") + assert "20" in output + assert "nan" not in output.lower() + assert model.framework is None diff --git a/pyproject.toml b/pyproject.toml index 152c6dc0..12123b6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "ruff>=0.11.1", "pre-commit>=4.2.0", "mypy>=1.18.0", + "maketables>=0.2.0", ] [project.urls] From 756e4435387d4860f2e60a05c65f82293b6ef8a7 Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:17:58 -0800 Subject: [PATCH 3/5] fix: hide stale MakeTables intervals after resetting sample splits --- doubleml/utils/maketables_mixin.py | 7 ++++--- doubleml/utils/tests/test_maketables_mixin.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/doubleml/utils/maketables_mixin.py b/doubleml/utils/maketables_mixin.py index f4dafeab..a2b89666 100644 --- a/doubleml/utils/maketables_mixin.py +++ b/doubleml/utils/maketables_mixin.py @@ -1,5 +1,6 @@ """MakeTables plug-in support for models inheriting from DoubleML.""" +import numpy as np import pandas as pd @@ -24,10 +25,10 @@ def __maketables_coef_table__(self) -> pd.DataFrame: Notes ----- - Before fitting, returns an empty table with the same columns, consistent - with ``DoubleML.summary``. No coefficient estimates are displayed. + Before fitting or after resetting sample splits, returns an empty table + with the same columns, consistent with ``DoubleML.summary``. """ - if self.framework is None: + if self.framework is None or np.isnan(self.coef).all(): return pd.DataFrame(columns=["b", "se", "t", "p", "ci95l", "ci95u"]) ci = self.confint(joint=False, level=0.95) diff --git a/doubleml/utils/tests/test_maketables_mixin.py b/doubleml/utils/tests/test_maketables_mixin.py index eab22a19..569f3541 100644 --- a/doubleml/utils/tests/test_maketables_mixin.py +++ b/doubleml/utils/tests/test_maketables_mixin.py @@ -1,5 +1,7 @@ """Test MakeTables integration against fitted DoubleML estimates and rendered tables.""" +from copy import deepcopy + import numpy as np import pandas as pd import pytest @@ -111,3 +113,18 @@ def test_maketables_before_fit(): assert "20" in output assert "nan" not in output.lower() assert model.framework is None + + +@pytest.mark.ci +def test_maketables_reset_and_refit(fitted_model): + """Resetting sample splits must hide old intervals until the model is refitted.""" + model = deepcopy(fitted_model) + before = model.__maketables_coef_table__ + model.set_sample_splitting(model.smpls, model.smpls_cluster) + assert model.summary.empty + assert model.__maketables_coef_table__.empty + output = ETable(model, coef_fmt="b:.6f [ci95l:.6f, ci95u:.6f]").make("tex") + for value in before["ci95l"]: + assert f"{value:.6f}" not in output + model.fit() + pd.testing.assert_frame_equal(model.__maketables_coef_table__, before) From 7d9cd18f9b78b826fc2609f481e06715bc6ddab3 Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:20:51 -0800 Subject: [PATCH 4/5] test: validate LPLR tuning on its optimization folds --- .../plm/tests/test_lplr_tune_ml_models.py | 114 +++++++++--------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/doubleml/plm/tests/test_lplr_tune_ml_models.py b/doubleml/plm/tests/test_lplr_tune_ml_models.py index eb44b163..25f9957b 100644 --- a/doubleml/plm/tests/test_lplr_tune_ml_models.py +++ b/doubleml/plm/tests/test_lplr_tune_ml_models.py @@ -1,15 +1,15 @@ +"""Validate LPLR tuning against its cross-validation objective and applied parameters.""" + import numpy as np +import optuna import pytest +from sklearn.base import clone +from sklearn.model_selection import KFold, cross_val_score from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor import doubleml as dml from doubleml.plm.datasets import make_lplr_LZZ2020 -from doubleml.tests._utils_tune_optuna import ( - _SAMPLER_CASES, - _assert_tree_params, - _basic_optuna_settings, - _small_tree_params, -) +from doubleml.tests._utils_tune_optuna import _assert_tree_params, _basic_optuna_settings, _small_tree_params @pytest.fixture(scope="module", params=["nuisance_space", "instrument"]) @@ -17,77 +17,71 @@ def score(request): return request.param -@pytest.fixture( - scope="module", - params=[DecisionTreeRegressor(random_state=567), None], -) +@pytest.fixture(scope="module", params=[DecisionTreeRegressor(random_state=567), None]) def ml_a(request): return request.param @pytest.mark.ci -@pytest.mark.parametrize("sampler_name,optuna_sampler", _SAMPLER_CASES, ids=[case[0] for case in _SAMPLER_CASES]) -def test_doubleml_lplr_optuna_tune(sampler_name, optuna_sampler, score, ml_a): +@pytest.mark.parametrize("sampler_class", [optuna.samplers.RandomSampler, optuna.samplers.TPESampler], ids=["random", "tpe"]) +def test_doubleml_lplr_optuna_tune(sampler_class, score, ml_a): + """Tuning improves its own CV objective and applies the selected parameters.""" np.random.seed(3141) - alpha = 0.5 - dml_data = make_lplr_LZZ2020(n_obs=500, dim_x=15, alpha=alpha) - - ml_M = DecisionTreeClassifier(random_state=123) - ml_t = DecisionTreeRegressor(random_state=234) - ml_m = DecisionTreeRegressor(random_state=456) - + dml_data = make_lplr_LZZ2020(n_obs=500, dim_x=15, alpha=0.5) dml_lplr = dml.DoubleMLLPLR( dml_data, - ml_M=ml_M, - ml_t=ml_t, - ml_m=ml_m, + ml_M=DecisionTreeClassifier(random_state=123), + ml_t=DecisionTreeRegressor(random_state=234), + ml_m=DecisionTreeRegressor(random_state=456), ml_a=ml_a, n_folds=2, n_folds_inner=2, score=score, ) - dml_lplr.fit() - untuned_score = dml_lplr.evaluate_learners() - - optuna_params = { - "ml_M": _small_tree_params, - "ml_m": _small_tree_params, - "ml_t": _small_tree_params, - "ml_a": _small_tree_params, + cv = KFold(n_splits=5, shuffle=True, random_state=42) + x, y, d = dml_data.x, dml_data.y, dml_data.d + mask_m = y == 0 if score == "nuisance_space" else np.ones(len(y), dtype=bool) + tuning_data = { + "ml_M": (np.column_stack((d, x)), y, "neg_log_loss"), + "ml_m": (x[mask_m], d[mask_m], "neg_root_mean_squared_error"), + "ml_a": (x, d, "neg_root_mean_squared_error"), } - + baseline_scores = { + name: cross_val_score(clone(dml_lplr.learner[name]), features, target, cv=cv, scoring=scorer).mean() + for name, (features, target, scorer) in tuning_data.items() + } + optuna_params = {name: _small_tree_params for name in ["ml_M", "ml_m", "ml_a", "ml_t"]} tune_res = dml_lplr.tune_ml_models( ml_param_space=optuna_params, - optuna_settings=_basic_optuna_settings({"sampler": optuna_sampler, "n_trials": 5}), + cv=cv, + optuna_settings=_basic_optuna_settings({"sampler": sampler_class(seed=3141), "n_trials": 5}), return_tune_res=True, ) - dml_lplr.fit() - tuned_score = dml_lplr.evaluate_learners() - - tuned_params_M = tune_res[0]["ml_M"].best_params - tuned_params_t = tune_res[0]["ml_t"].best_params - tuned_params_m = tune_res[0]["ml_m"].best_params - tuned_params_a = tune_res[0]["ml_a"].best_params + assert len(tune_res) == 1 + assert set(tune_res[0]) == set(optuna_params) + for name, result in tune_res[0].items(): + assert result.tuned + _assert_tree_params(result.best_params) + assert dml_lplr.get_params(name)["d"] == [[result.best_params] * dml_lplr.n_folds] + for key, value in result.best_params.items(): + assert result.best_estimator.get_params()[key] == value + + for name, (features, target, scorer) in tuning_data.items(): + result = tune_res[0][name] + verified_score = cross_val_score(clone(result.best_estimator), features, target, cv=cv, scoring=scorer).mean() + assert result.scoring_method == scorer + assert result.best_score == pytest.approx(verified_score) + assert verified_score > baseline_scores[name] - _assert_tree_params(tuned_params_M) - _assert_tree_params(tuned_params_t) - _assert_tree_params(tuned_params_m) - _assert_tree_params(tuned_params_a) - - # ensure results contain optuna objects and best params - assert isinstance(tune_res[0], dict) - assert set(tune_res[0].keys()) == {"ml_M", "ml_m", "ml_t", "ml_a"} - assert hasattr(tune_res[0]["ml_M"], "best_params") - assert tune_res[0]["ml_M"].best_params["max_depth"] == tuned_params_M["max_depth"] - assert hasattr(tune_res[0]["ml_t"], "best_params") - assert tune_res[0]["ml_t"].best_params["max_depth"] == tuned_params_t["max_depth"] - assert hasattr(tune_res[0]["ml_m"], "best_params") - assert tune_res[0]["ml_m"].best_params["max_depth"] == tuned_params_m["max_depth"] - assert hasattr(tune_res[0]["ml_a"], "best_params") - assert tune_res[0]["ml_a"].best_params["max_depth"] == tuned_params_a["max_depth"] - - # ensure tuning improved RMSE # not actually possible for ml_t as the targets are not available - assert tuned_score["ml_M"] < untuned_score["ml_M"] - assert tuned_score["ml_m"] < untuned_score["ml_m"] - assert tuned_score["ml_a"] < untuned_score["ml_a"] + dml_lplr.fit() + assert np.isfinite(dml_lplr.coef).all() + assert np.isfinite(dml_lplr.se).all() + + expected_a = np.empty_like(d) + for (_, test), inner_splits in zip(dml_lplr.smpls[0], dml_lplr.smpls_inner[0]): + predictions = [ + clone(tune_res[0]["ml_a"].best_estimator).fit(x[train], d[train]).predict(x[test]) for train, _ in inner_splits + ] + expected_a[test] = np.mean(predictions, axis=0) + np.testing.assert_allclose(dml_lplr.predictions["ml_a"][:, 0, 0], expected_a) From b931dbfa5fac134435a5ccc5ce74cf0bdd26f873 Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:52 -0800 Subject: [PATCH 5/5] test: fit an independent model for sensitivity defaults --- doubleml/tests/test_model_defaults.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doubleml/tests/test_model_defaults.py b/doubleml/tests/test_model_defaults.py index 14650f39..31bc3dd2 100644 --- a/doubleml/tests/test_model_defaults.py +++ b/doubleml/tests/test_model_defaults.py @@ -1,3 +1,5 @@ +"""Test model defaults independently of the execution order.""" + import numpy as np import pytest from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor @@ -224,6 +226,7 @@ def test_apos_defaults(): @pytest.mark.ci def test_sensitivity_defaults(): + dml_plr = dml.DoubleMLPLR(dml_data_plr, Lasso(), Lasso()).fit() input_dict = {"cf_y": 0.03, "cf_d": 0.03, "rho": 1.0, "level": 0.95, "null_hypothesis": np.array([0.0])} dml_plr.sensitivity_analysis()