From 8c649b3179a63d6c4f2953df28dda397590c18e2 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Tue, 3 Jun 2025 08:59:32 -0400 Subject: [PATCH 01/13] Remove model package dependencies --- pyproject.toml | 8 +++++++- requirements.txt | 4 +--- statstables/__init__.py | 31 +++++++++---------------------- statstables/tables.py | 8 ++++---- 4 files changed, 21 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9dbed53..223a727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,15 @@ dependencies = [ "numpy", "pandas>=2", "scipy", + "unicodeit", +] + +[project.optional-dependencies] +test = [ + "pytest>=6.0", + "pytest-cov>=2.0", "statsmodels", "linearmodels", - "unicodeit", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 17d43bc..98415fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ numpy pandas scipy -statsmodels -linearmodels unicodeit -Faker \ No newline at end of file +Faker diff --git a/statstables/__init__.py b/statstables/__init__.py index 6e0ffce..b661d3c 100644 --- a/statstables/__init__.py +++ b/statstables/__init__.py @@ -7,18 +7,6 @@ cellformatting, ) from statstables.parameters import STParams -from statsmodels.base.wrapper import ResultsWrapper -from statsmodels.regression.linear_model import RegressionResultsWrapper -from statsmodels.discrete.discrete_model import ( - BinaryResultsWrapper, - PoissonResultsWrapper, -) -from linearmodels.iv.results import IVResults, OLSResults -from linearmodels.panel.results import ( - PanelEffectsResults, - PanelResults, - RandomEffectsResults, -) __all__ = [ "STParams", @@ -27,19 +15,18 @@ "modeltables", "renderers", "utils", - "ResultsWrapper", "parameters", "cellformatting", ] SupportedModels = { - RegressionResultsWrapper: modeltables.StatsModelsData, - ResultsWrapper: modeltables.StatsModelsData, - BinaryResultsWrapper: modeltables.StatsModelsData, - PoissonResultsWrapper: modeltables.StatsModelsData, - IVResults: modeltables.LinearModelsData, - OLSResults: modeltables.LinearModelsData, - PanelEffectsResults: modeltables.LinearModelsData, - PanelResults: modeltables.LinearModelsData, - RandomEffectsResults: modeltables.LinearModelsData, + "RegressionResultsWrapper": modeltables.StatsModelsData, + "ResultsWrapper": modeltables.StatsModelsData, + "BinaryResultsWrapper": modeltables.StatsModelsData, + "PoissonResultsWrapper": modeltables.StatsModelsData, + "IVResults": modeltables.LinearModelsData, + "OLSResults": modeltables.LinearModelsData, + "PanelEffectsResults": modeltables.LinearModelsData, + "PanelResults": modeltables.LinearModelsData, + "RandomEffectsResults": modeltables.LinearModelsData, } diff --git a/statstables/tables.py b/statstables/tables.py index cc4bc86..de35ffc 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -1047,9 +1047,9 @@ def _create_rows(self): se = self.sem.loc[_index, col] formatted_se = copy.copy(formatted_val) # formatted_se = self._format_value(_index, col, se) - formatted_se["value"] = ( - f"({se:,.{self.table_params['sig_digits']}f})" - ) + formatted_se[ + "value" + ] = f"({se:,.{self.table_params['sig_digits']}f})" sem_row.append(formatted_se) except KeyError: sem_row.append(self._format_value(_index, col, "")) @@ -1204,7 +1204,7 @@ def __init__( # pull the parameters from each model for mod in models: try: - mod_obj = st.SupportedModels[type(mod)](mod) + mod_obj = st.SupportedModels[mod.__class__.__name__](mod) self.models.append(mod_obj) except KeyError as e: msg = ( From 70484f9df2ee5ac4b9695119118a8e52ef34aa67 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Tue, 3 Jun 2025 09:06:03 -0400 Subject: [PATCH 02/13] update sample notebook --- main.pdf | Bin 100452 -> 100452 bytes samplenotebook.ipynb | 7 +++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/main.pdf b/main.pdf index 5e3251e3c3570998baf0d0eaf8253f29aba3b177..8f61c912e45eee0cd17145239ce263fe484a2eb4 100644 GIT binary patch delta 155 zcmaDdf$hlzwuUW?eswx#2F3=K2Bs#4x&|id1_tVyT>8HGDK3d6sR|k{Rz?O!rUq~& z+pFprQ#k?++$@cpjVz6v3=ACIoJ8HGDK3d6sR|k{Rz?O!rUq~& z+pFprQ#k@1E! Date: Tue, 3 Jun 2025 15:30:58 -0400 Subject: [PATCH 03/13] remove model types --- statstables/modeltables.py | 27 ++------------------------- statstables/tests/test_tables.py | 1 + 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/statstables/modeltables.py b/statstables/modeltables.py index 80e2831..40c2e50 100644 --- a/statstables/modeltables.py +++ b/statstables/modeltables.py @@ -2,29 +2,8 @@ import statstables as st from .tables import Table from abc import ABC, abstractmethod -from typing import Any, TypeAlias +from typing import Any from dataclasses import dataclass -from statsmodels.base.wrapper import ResultsWrapper -from statsmodels.regression.linear_model import RegressionResultsWrapper -from statsmodels.discrete.discrete_model import BinaryResultsWrapper -from linearmodels.iv.results import IVResults, OLSResults -from linearmodels.panel.results import ( - PanelEffectsResults, - PanelResults, - RandomEffectsResults, -) - -ModelTypes: TypeAlias = ( - ResultsWrapper - | RegressionResultsWrapper - | IVResults - | OLSResults - | PanelEffectsResults - | PanelResults - | RandomEffectsResults - | BinaryResultsWrapper - | Any -) # model stats that should always be formatted as integers INT_VARS = ["observations", "ngroups"] @@ -32,7 +11,7 @@ @dataclass class ModelData(ABC): - model: ModelTypes + model: Any def __post_init__(self): self.data = {} @@ -83,7 +62,6 @@ def __getattr__(self, name) -> Any: @dataclass class StatsModelsData(ModelData): - def __post_init__(self): super().__post_init__() self.summary_parameters = [ @@ -128,7 +106,6 @@ def pull_params(self) -> None: @dataclass class LinearModelsData(ModelData): - def __post_init__(self): super().__post_init__() self.summary_parameters = [ diff --git a/statstables/tests/test_tables.py b/statstables/tests/test_tables.py index 08d1505..6a9c902 100644 --- a/statstables/tests/test_tables.py +++ b/statstables/tests/test_tables.py @@ -136,6 +136,7 @@ def test_long_table(): temp_path.unlink() except AssertionError as e: msg = f"longtable expected output has changed. New output in {str(temp_path)}" + print(msg) Path(CUR_PATH, "..", "..", "longtableactual.tex").write_text(longtable_tex) raise e From f8783e7929241b16dd9047e24cb68118235ff887 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 11 Jun 2025 10:48:14 -0400 Subject: [PATCH 04/13] add pyfixest support --- statstables/__init__.py | 57 +++++++++++++--- statstables/modeltables.py | 27 +++++++- statstables/tables.py | 11 +-- statstables/tests/test_tables.py | 112 +++++++++++++++++++++++++++++-- 4 files changed, 180 insertions(+), 27 deletions(-) diff --git a/statstables/__init__.py b/statstables/__init__.py index b661d3c..a0c89c0 100644 --- a/statstables/__init__.py +++ b/statstables/__init__.py @@ -1,3 +1,4 @@ +from typing import Any from statstables import ( tables, renderers, @@ -19,14 +20,48 @@ "cellformatting", ] -SupportedModels = { - "RegressionResultsWrapper": modeltables.StatsModelsData, - "ResultsWrapper": modeltables.StatsModelsData, - "BinaryResultsWrapper": modeltables.StatsModelsData, - "PoissonResultsWrapper": modeltables.StatsModelsData, - "IVResults": modeltables.LinearModelsData, - "OLSResults": modeltables.LinearModelsData, - "PanelEffectsResults": modeltables.LinearModelsData, - "PanelResults": modeltables.LinearModelsData, - "RandomEffectsResults": modeltables.LinearModelsData, -} + +class SupportedModelsClass(dict): + def __init__(self, models: dict): + super().__init__() + self.models = models + + @staticmethod + def _keyname(key: str): + # return f"" + return key.replace("", "") + + def __setitem__(self, key: str, value: Any): + msg = "Custom models must inherit from the ModelData class" + assert value.__base__ == modeltables.ModelData, msg + self.models[key] = value + + def __getitem__(self, key: str): + try: + return self.models[key] + except KeyError: + try: + return self.models[self._keyname(key)] + except KeyError: + msg = ( + f"{key} is unsupported. To use custom models, " + "add them to the `st.SupportedModels` dictionary." + ) + raise KeyError(msg) + + +SupportedModels = SupportedModelsClass( + { + "statsmodels.regression.linear_model.RegressionResultsWrapper": modeltables.StatsModelsData, + "statsmodels.base.wrapper.ResultsWrapper": modeltables.StatsModelsData, + "statsmodels.discrete.discrete_model.BinaryResultsWrapper": modeltables.StatsModelsData, + "statsmodels.discrete.discrete_model.PoissonResultsWrapper": modeltables.StatsModelsData, + "linearmodels.iv.results.IVResults": modeltables.LinearModelsData, + "linearmodels.iv.results.OLSResults": modeltables.LinearModelsData, + "linearmodels.panel.results.PanelEffectsResults": modeltables.LinearModelsData, + "linearmodels.panel.results.PanelResults": modeltables.LinearModelsData, + "linearmodels.panel.results.RandomEffectsResults": modeltables.LinearModelsData, + "pyfixest.estimation.feols_.Feols": modeltables.PyFixestModel, + "pyfixest.estimation.fepois_.Fepois": modeltables.PyFixestModel, + } +) diff --git a/statstables/modeltables.py b/statstables/modeltables.py index 40c2e50..210396e 100644 --- a/statstables/modeltables.py +++ b/statstables/modeltables.py @@ -134,7 +134,28 @@ def pull_params(self) -> None: self.data["dependent_variable"] = self.model.summary.tables[0].data[0][1] self.data["fstat"] = self.model.f_statistic.stat self.data["fstat_pvalue"] = self.model.f_statistic.pval - if isinstance( - self.model, (PanelEffectsResults, RandomEffectsResults, PanelResults) - ): + if self.model.__class__.__name__ in [ + "PanelEffectsResults", + "RandomEffectsResults", + "PanelResults", + ]: self.data["ngroups"] = self.model.entity_info.total + + +PYFIXEST_MAP = {"params": "coef"} + + +@dataclass +class PyFixestModel(ModelData): + def __post_init__(self): + super().__post_init__() + + def pull_params(self): + params = self.model.coef() + self.data["params"] = params + self.data["param_labels"] = set(params.index.values) + confint = self.model.confint() + self.data["cis_low"] = confint["2.5%"] + self.data["cis_high"] = confint["97.5%"] + self.data["dependent_variable"] = self.model._depvar + self.data["pvalues"] = self.model.pvalue() diff --git a/statstables/tables.py b/statstables/tables.py index de35ffc..1a1f975 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -1203,15 +1203,8 @@ def __init__( dep_vars = [] # pull the parameters from each model for mod in models: - try: - mod_obj = st.SupportedModels[mod.__class__.__name__](mod) - self.models.append(mod_obj) - except KeyError as e: - msg = ( - f"{type(mod)} is unsupported. To use custom models, " - "add them to the `st.SupportedModels` dictionary." - ) - raise KeyError(msg) from e + mod_obj = st.SupportedModels[str(type(mod))](mod) + self.models.append(mod_obj) self.params.update(mod_obj.param_labels) dep_vars.append(mod_obj.dependent_variable) diff --git a/statstables/tests/test_tables.py b/statstables/tests/test_tables.py index 6a9c902..4c4a955 100644 --- a/statstables/tests/test_tables.py +++ b/statstables/tests/test_tables.py @@ -6,9 +6,16 @@ import pandas as pd import numpy as np import statsmodels.formula.api as smf -from statstables import tables +import pyfixest as pf +from linearmodels.datasets import mroz +from linearmodels.iv import IV2SLS +from linearmodels.datasets import wage_panel +from linearmodels.panel import PooledOLS, RandomEffects, PanelOLS +from statsmodels.api import add_constant +from statstables import tables, modeltables, SupportedModels from faker import Faker from pathlib import Path +from dataclasses import dataclass CUR_PATH = Path(__file__).resolve().parent @@ -94,7 +101,7 @@ def test_mean_differences_table(data): assert table.table_params["include_index"] == True -def test_model_table(data): +def test_model_table_statsmodels(data): mod1 = smf.ols("A ~ B + C -1", data=data).fit() mod2 = smf.ols("A ~ B + C", data=data).fit() mod_table = tables.ModelTable(models=[mod1, mod2]) @@ -116,6 +123,102 @@ def test_model_table(data): assert binary_table.table_params["include_index"] == True +def test_model_table_linearmodels(): + """ + Test model table with linear models results + """ + # IV results + data = mroz.load() + data = data.dropna() + data = add_constant(data, has_constant="add") + iv = IV2SLS(np.log(data.wage), data[["const"]], data.educ, data.fatheduc).fit( + cov_type="unadjusted" + ) + ivtable = tables.ModelTable(models=[iv.first_stage.individual["educ"], iv]) + ivtable.rename_covariates( + { + "const": "Intercept", + "educ": "Education", + "fatheduc": "Father Education", + } + ) + ivtable.parameter_order(["const", "fatheduc", "educ"]) + + # panel and random effects models + data = wage_panel.load() + year = pd.Categorical(data.year) + data = data.set_index(["nr", "year"]) + data["year"] = year + exog_vars = [ + "black", + "hisp", + "exper", + "expersq", + "married", + "educ", + "union", + "year", + ] + exog = add_constant(data[exog_vars]) + pooled_mod = PooledOLS(data.lwage, exog).fit() + random_mod = RandomEffects(data.lwage, exog).fit() + exog_vars = [ + "expersq", + "union", + "married", + ] + panel_exog = add_constant(data[exog_vars]) + panel_mod = PanelOLS( + data.lwage, panel_exog, entity_effects=True, time_effects=True + ).fit() + panel_table = tables.ModelTable([pooled_mod, random_mod, panel_mod]) + panel_table.dependent_variable_name = "Log(Wage)" + panel_table.rename_covariates( + { + "const": "Intercept", + "exper": "Experience", + "expersq": "Experience Squared", + "union": "Union", + "married": "Married", + "black": "Black", + } + ) + panel_table.parameter_order( + ["const", "exper", "expersq", "union", "married", "black"] + ) + + +def test_model_table_pyfixest(): + data = pf.get_data() + feols = pf.feols("Y ~ X1 | f1 + f2", data=data) + poisson_data = pf.get_data(model="Fepois") + fepois = pf.fepois("Y ~ X1 + X2 | f1 + f2", data=poisson_data) + tables.ModelTable([feols, fepois]) + + +def test_custom_model_table(): + # test adding a custom model + @dataclass + class CustomTable(modeltables.ModelData): + def __post_init__(self): + super().__post_init__() + ... + + def pull_params(self): + pass + + SupportedModels["custom_model"] = CustomTable + + # test bad model instance + @dataclass + class BadCustomTable: + def __post__init(self): + ... + + with pytest.raises(AssertionError): + SupportedModels["bad_model"] = BadCustomTable + + def test_long_table(): fake = Faker() Faker.seed(512) @@ -169,11 +272,12 @@ def test_panel_table(): temp_path = Path("panel_table_actual.tex") panel.render_latex(outfile=temp_path) panel_tex = temp_path.read_text() + temp_path.unlink() expected_tex = Path(CUR_PATH, "..", "..", "panel.tex").read_text() try: assert panel_tex == expected_tex - temp_path.unlink() except AssertionError as e: msg = f"panel table expected output has changed. New output in {str(temp_path)}" - Path(CUR_PATH, "..", "..", "paneltableactual.tex").write_text(panel) + print(msg) + Path(CUR_PATH, "..", "..", "paneltableactual.tex").write_text(panel_tex) raise e From 48db70269276075c54979c61c01ff22501aca234 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 11 Jun 2025 10:49:09 -0400 Subject: [PATCH 05/13] add pyfixest testing dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 223a727..19ba297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ test = [ "pytest-cov>=2.0", "statsmodels", "linearmodels", + "pyfixest" ] [project.urls] From 6cbfc17572a440a136013441abbaf165e4c3cd11 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 11 Jun 2025 11:03:59 -0400 Subject: [PATCH 06/13] add test requirements file --- .github/workflows/python-package.yml | 44 ++++++++++++++-------------- pyproject.toml | 4 +++ requirements.txt | 1 - test_requirements.txt | 7 +++++ 4 files changed, 33 insertions(+), 23 deletions(-) create mode 100644 test_requirements.txt diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9bf4aa1..fa4532d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,13 +5,12 @@ name: Python package on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: - runs-on: ubuntu-latest strategy: fail-fast: false @@ -19,22 +18,23 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f test_requirements.txt ]; then pip install -r test_requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/pyproject.toml b/pyproject.toml index 19ba297..d8600f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,10 @@ test = [ "pyfixest" ] +dev = [ + "statstables[test]" +] + [project.urls] Homepage = "https://github.com/andersonfrailey/statstables" Issues = "https://github.com/andersonfrailey/statstables/issues" diff --git a/requirements.txt b/requirements.txt index 98415fa..08a9e16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ numpy pandas scipy unicodeit -Faker diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..3185f5f --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,7 @@ +numpy +pandas +scipy +unicodeit +statsmodels +linearmodels +pyfixest From 3b73842f76c70ea378c69d59b6a70fdb8527410a Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 11 Jun 2025 13:49:44 -0400 Subject: [PATCH 07/13] add faker test dependency --- pyproject.toml | 1 + test_requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d8600f5..8c3954b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ test = [ "statsmodels", "linearmodels", "pyfixest" + "faker" ] dev = [ diff --git a/test_requirements.txt b/test_requirements.txt index 3185f5f..0bd689c 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -5,3 +5,4 @@ unicodeit statsmodels linearmodels pyfixest +faker From 296e165c12d66a49bb88ccc5e344d28061b4b14b Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 11 Jun 2025 14:13:44 -0400 Subject: [PATCH 08/13] fix bug in pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8c3954b..96b182a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,8 @@ test = [ "pytest-cov>=2.0", "statsmodels", "linearmodels", - "pyfixest" - "faker" + "pyfixest", + "faker", ] dev = [ From f24b532893c4c64306d62c8a506b71d0d7e6029d Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Thu, 24 Jul 2025 08:56:14 -0400 Subject: [PATCH 09/13] update default parameters --- statstables/parameters.py | 2 +- statstables/tables.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/statstables/parameters.py b/statstables/parameters.py index 81c7ae4..9c30be5 100644 --- a/statstables/parameters.py +++ b/statstables/parameters.py @@ -182,7 +182,7 @@ def _validate_param(self, name, value): "show_model_numbers": True, "p_values": [0.1, 0.05, 0.01], "show_stars": True, - "show_model_type": True, + "show_model_type": False, "dependent_variable": "", "include_index": True, "show_significance_levels": True, diff --git a/statstables/tables.py b/statstables/tables.py index 1a1f975..3b7e410 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -1515,7 +1515,7 @@ def __init__( assert panel_label_alignment in self.VALID_ALIGNMENTS self.panel_label_alignment = panel_label_alignment - def render_latex(self, outfile) -> str | None: + def render_latex(self, outfile, **kwargs) -> str | None: # assign multicolumns to each table match self.enumerate_type: case "alpha_upper": From 3889a8cd4809b53716f5d6e2e116bb67b6df32b9 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Fri, 26 Sep 2025 15:13:13 -0400 Subject: [PATCH 10/13] Add custom model test --- samplenotebook.ipynb | 30 ++++++++- statstables/__init__.py | 23 ++++++- statstables/tables.py | 8 +-- statstables/tests/test_tables.py | 103 ++++++++++++++++++++++--------- 4 files changed, 124 insertions(+), 40 deletions(-) diff --git a/samplenotebook.ipynb b/samplenotebook.ipynb index dc00787..a247026 100644 --- a/samplenotebook.ipynb +++ b/samplenotebook.ipynb @@ -69,7 +69,7 @@ "type": "integer" } ], - "ref": "17265e91-d8c9-48d3-b5fa-53f7527e7e2a", + "ref": "7613a0d1-a193-4e48-a636-208d62c77a62", "rows": [ [ "0", @@ -2088,11 +2088,35 @@ "from yourmodelpackage import ModelOutputClass\n", "\n", "class CustomModelClass(st.modeltables.ModelData):\n", + " \"\"\"\n", + " Class used to pull the parameters of the model.\n", + " See statstables/modeltables.py for full implementation examples.\n", + " \"\"\"\n", " ...\n", "\n", - "st.SupportedModels[\"ModelOutputClass\"] = CustomModelClass\n", + "st.SupportedModels.add_model(ModelOutputClass, CustomModelClass)\n", + "```\n", + "where `\"ModelOutputClass\"` is the type of object returned after fitting the model.\n", + "\n", + "For example, if `statsmodels` were not supported (for the record, it is) but you wanted to create tables from output from those regression models, you would do:\n", + "```python\n", + "# import the object that is returned when you fit the model\n", + "from statsmodels.regression.linear_model import RegressionResultsWrapper\n", + "from dataclass import dataclass\n", + "\n", + "@dataclass\n", + "class StatsModelsData(st.modeltables.ModelData):\n", + " \"\"\"\n", + " Create a class for processing the model data.\n", + " \"\"\"\n", + " def __post_init__(self):\n", + " ...\n", + "\n", + " def pull_params(self):\n", + " ...\n", + "\n", + "st.SupportedModels.add_model(RegressionResultsWrapper, StatsModelsData) \n", "```\n", - "where `\"ModelOutputClass\"` is the type of object returned after fitting the model. Internally, `statstables` calls `model.__class__.__name__` on the model passed into `ModelTable` initializer, so the key will need to match that value for your custom model.\n", "\n", "## Formatting\n", "\n", diff --git a/statstables/__init__.py b/statstables/__init__.py index a0c89c0..68c0bbc 100644 --- a/statstables/__init__.py +++ b/statstables/__init__.py @@ -26,9 +26,23 @@ def __init__(self, models: dict): super().__init__() self.models = models + def add_model(self, model_results_class: Any, output_class: Any) -> None: + """ + Add a custom model without giving it a default name + + Parameters + ---------- + model_results_class : Any + The model class you want to add + + Returns + ------- + None + """ + self[type(model_results_class)] = output_class + @staticmethod def _keyname(key: str): - # return f"" return key.replace("", "") def __setitem__(self, key: str, value: Any): @@ -37,11 +51,14 @@ def __setitem__(self, key: str, value: Any): self.models[key] = value def __getitem__(self, key: str): + # custom models will be saved with their type as the key, but the natively + # supported models are passed in as strings (see initialization below) + # so they will be found in the first exception try: - return self.models[key] + return self.models[type(key)] except KeyError: try: - return self.models[self._keyname(key)] + return self.models[self._keyname(str(key))] except KeyError: msg = ( f"{key} is unsupported. To use custom models, " diff --git a/statstables/tables.py b/statstables/tables.py index 0cf66ca..85e0d61 100644 --- a/statstables/tables.py +++ b/statstables/tables.py @@ -1086,9 +1086,9 @@ def _create_rows(self) -> list[list[ChainMap]]: se = self.sem.loc[_index, col] formatted_se = copy.copy(formatted_val) # formatted_se = self._format_value(_index, col, se) - formatted_se[ - "value" - ] = f"({se:,.{self.table_params['sig_digits']}f})" + formatted_se["value"] = ( + f"({se:,.{self.table_params['sig_digits']}f})" + ) sem_row.append(formatted_se) except KeyError: sem_row.append(self._format_value(_index, col, "")) @@ -1244,7 +1244,7 @@ def __init__( dep_vars = [] # pull the parameters from each model for mod in models: - mod_obj = st.SupportedModels[str(type(mod))](mod) + mod_obj = st.SupportedModels[type(mod)](mod) self.models.append(mod_obj) self.params.update(mod_obj.param_labels) dep_vars.append(mod_obj.dependent_variable) diff --git a/statstables/tests/test_tables.py b/statstables/tests/test_tables.py index 12b59fd..4894b03 100644 --- a/statstables/tests/test_tables.py +++ b/statstables/tests/test_tables.py @@ -2,26 +2,21 @@ Tests implementation of tables """ +import copy import pytest import pandas as pd import numpy as np import statsmodels.formula.api as smf import pyfixest as pf -from linearmodels.datasets import mroz +from linearmodels.datasets import mroz, wage_panel from linearmodels.iv import IV2SLS -from linearmodels.datasets import wage_panel from linearmodels.panel import PooledOLS, RandomEffects, PanelOLS from statsmodels.api import add_constant from statstables import tables, modeltables, SupportedModels from faker import Faker from pathlib import Path from dataclasses import dataclass -from statsmodels.api import add_constant -from statstables import tables -from faker import Faker -from pathlib import Path -from linearmodels.datasets import wage_panel -from linearmodels.panel import PooledOLS, RandomEffects, PanelOLS + CUR_PATH = Path(__file__).resolve().parent @@ -202,28 +197,6 @@ def test_model_table_pyfixest(): tables.ModelTable([feols, fepois]) -def test_custom_model_table(): - # test adding a custom model - @dataclass - class CustomTable(modeltables.ModelData): - def __post_init__(self): - super().__post_init__() - ... - - def pull_params(self): - pass - - SupportedModels["custom_model"] = CustomTable - - # test bad model instance - @dataclass - class BadCustomTable: - def __post__init(self): ... - - with pytest.raises(AssertionError): - SupportedModels["bad_model"] = BadCustomTable - - def test_long_table(): fake = Faker() Faker.seed(512) @@ -336,6 +309,76 @@ def test_linear_models(): ) +def test_custom_model_table(data): + # test adding a custom model + @dataclass + class CustomTable(modeltables.ModelData): + def __post_init__(self): + super().__post_init__() + ... + + def pull_params(self): + pass + + SupportedModels["custom_model"] = CustomTable + + # test bad model instance + @dataclass + class BadCustomTable: + def __post__init(self): ... + + with pytest.raises(AssertionError): + SupportedModels["bad_model"] = BadCustomTable + + # create a fake custom class by wrapping statsmodels results to test + PARAMETER_MAP = { + "params": "params", + "sterrs": "bse", + "r2": "rsquared", + "pvalues": "pvalues", + "adjusted_r2": "rsquared_adj", + "fstat": "fvalue", + "fstat_pvalue": "f_pvalue", + "observations": "nobs", + "dof_model": "df_model", + "dof_resid": "df_resid", + } + + class ModelWrapper: + """ + Wraps the statsmodels results to use as an example for the test + """ + + def __init__(self, result): + for _, attr in PARAMETER_MAP.items(): + setattr(self, attr, getattr(result, attr)) + self.conf_int = result.conf_int() + self.endog_names = result.model.endog_names + + @dataclass + class CustomResults(modeltables.ModelData): + def __post_init__(self): + super().__post_init__() + + def pull_params(self): + for info, attr in PARAMETER_MAP.items(): + try: + self.data[info] = getattr(self.model, attr) + except AttributeError: + pass + self.data["param_labels"] = set(self.model.params.index.values) + self.data["cis_low"] = self.model.conf_int[0] + self.data["cis_high"] = self.model.conf_int[1] + self.data["dependent_variable"] = self.model.endog_names + + mod = ModelWrapper(smf.ols("A ~ B + C", data=data).fit()) + SupportedModels.add_model(ModelWrapper, CustomResults) + table = tables.ModelTable([mod]) + table.render_latex() + table.render_html() + table.render_ascii() + + def compare_expected_output( expected_file: Path, actual_table: tables.Table, render_type: str, temp_file: Path ): From 50c45bf65dee4a7d96787f0169a420d7540b94f5 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Fri, 26 Sep 2025 16:56:33 -0400 Subject: [PATCH 11/13] fix pyfixest table standard errors bug --- statstables/modeltables.py | 1 + 1 file changed, 1 insertion(+) diff --git a/statstables/modeltables.py b/statstables/modeltables.py index 1b502c6..0063e74 100644 --- a/statstables/modeltables.py +++ b/statstables/modeltables.py @@ -152,6 +152,7 @@ def pull_params(self): params = self.model.coef() self.data["params"] = params self.data["param_labels"] = set(params.index.values) + self.data["sterrs"] = self.model.se() confint = self.model.confint() self.data["cis_low"] = confint["2.5%"] self.data["cis_high"] = confint["97.5%"] From b65d3eb885d2c0245e1417e4be263c4b352b0e37 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 5 Nov 2025 10:20:39 -0500 Subject: [PATCH 12/13] update tests --- main.pdf | Bin 90702 -> 95470 bytes main.tex | 6 ++ pyfixest_tables.tex | 22 ++++++ samplenotebook.ipynb | 126 ++++++++++++++++++++++++++++++- statstables/modeltables.py | 7 +- statstables/tests/test_tables.py | 34 ++++++--- 6 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 pyfixest_tables.tex diff --git a/main.pdf b/main.pdf index d1e61a04ccbb9fe5c1d0cb9d2fb464d299f1c330..fd53a94fb04802092f6335a28084425bbd7cca40 100644 GIT binary patch delta 7783 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 delta 3094 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 QU\n", + " \n", + " \n", + " \n", + " Dependent Variable: Y\n", + " \n", + " \n", + " \n", + " (1)\n", + " (2)\n", + " \n", + " \n", + " \n", + " \n", + " X1\n", + "\n", + " -0.919***\n", + "\n", + " -0.007\n", + "\n", + " \n", + " \n", + " \n", + "\n", + " (0.066)\n", + "\n", + " (0.035)\n", + "\n", + " \n", + " \n", + " X2\n", + "\n", + " \n", + "\n", + " -0.015\n", + "\n", + " \n", + " \n", + " \n", + "\n", + " \n", + "\n", + " (0.010)\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " Observations\n", + "\n", + " 997\n", + "\n", + " 997\n", + "\n", + " \n", + " \n", + " R2\n", + "\n", + " 0.609\n", + "\n", + " nan\n", + "\n", + " \n", + " *p<0.1, **p<0.05, ***p<0.01\n", + " \n", + "" + ], + "text/plain": [ + "==================================================\n", + "+ Dependent Variable: Y +\n", + "+ ------------------------------ +\n", + "+ (1) (2) +\n", + "+------------------------------------------------+\n", + "+ X1 -0.919*** -0.007 +\n", + "+ (0.066) (0.035) +\n", + "+ X2 -0.015 +\n", + "+ (0.010) +\n", + "--------------------------------------------------\n", + "+ Observations 997 997 +\n", + "+ R² 0.609 nan +\n", + "--------------------------------------------------\n", + "*p<0.1, **p<0.05, ***p<0.01 " + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pyfixest as pf\n", + "\n", + "data = pf.get_data()\n", + "feols = pf.feols(\"Y ~ X1 | f1 + f2\", data=data)\n", + "poisson_data = pf.get_data(model=\"Fepois\")\n", + "fepois = pf.fepois(\"Y ~ X1 + X2 | f1 + f2\", data=poisson_data)\n", + "pyfixest_table = tables.ModelTable([feols, fepois])\n", + "pyfixest_table.render_latex(outfile=\"pyfixest_tables.tex\", only_tabular=True)\n", + "pyfixest_table" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/statstables/modeltables.py b/statstables/modeltables.py index 0063e74..c029793 100644 --- a/statstables/modeltables.py +++ b/statstables/modeltables.py @@ -140,7 +140,7 @@ def pull_params(self) -> None: self.data["ngroups"] = self.model.entity_info.total -PYFIXEST_MAP = {"params": "coef"} +PYFIXEST_MAP = {"observations": "_N", "r2": "_r2"} @dataclass @@ -149,6 +149,11 @@ def __post_init__(self): super().__post_init__() def pull_params(self): + for info, attr in PYFIXEST_MAP.items(): + try: + self.data[info] = getattr(self.model, attr) + except AttributeError: + pass params = self.model.coef() self.data["params"] = params self.data["param_labels"] = set(params.index.values) diff --git a/statstables/tests/test_tables.py b/statstables/tests/test_tables.py index 4894b03..07e7146 100644 --- a/statstables/tests/test_tables.py +++ b/statstables/tests/test_tables.py @@ -194,7 +194,16 @@ def test_model_table_pyfixest(): feols = pf.feols("Y ~ X1 | f1 + f2", data=data) poisson_data = pf.get_data(model="Fepois") fepois = pf.fepois("Y ~ X1 + X2 | f1 + f2", data=poisson_data) - tables.ModelTable([feols, fepois]) + pyfixest_table = tables.ModelTable([feols, fepois]) + temp_path = Path("pyfixest_tables_actual.tex") + expected_path = Path(CUR_PATH, "..", "..", "pyfixest_tables.tex") + compare_expected_output( + expected_file=expected_path, + actual_table=pyfixest_table, + render_type="tex", + temp_file=temp_path, + only_tabular=True, + ) def test_long_table(): @@ -380,16 +389,23 @@ def pull_params(self): def compare_expected_output( - expected_file: Path, actual_table: tables.Table, render_type: str, temp_file: Path + expected_file: Path, + actual_table: tables.Table, + render_type: str, + temp_file: Path, + only_tabular: bool = False, ): match render_type: case "tex": - actual_table.render_latex(temp_file) + actual_table.render_latex(temp_file, only_tabular=only_tabular) actual_text = temp_file.read_text() expected_text = expected_file.read_text() - try: - assert actual_text == expected_text - temp_file.unlink() - except AssertionError as e: - msg = f"Output has changed. New output in {str(temp_file)}" - raise e(msg) + msg = f"Output has changed. New output in {str(temp_file)}" + assert actual_text == expected_text, msg + temp_file.unlink() + # try: + # assert actual_text == expected_text + # temp_file.unlink() + # except AssertionError as e: + # msg = f"Output has changed. New output in {str(temp_file)}" + # raise e(msg) From 76f43eeb832cdb9f58599bf5ca9a713766428fbc Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 5 Nov 2025 10:27:53 -0500 Subject: [PATCH 13/13] remove nan values from pyfixest rendering --- main.pdf | Bin 95470 -> 95462 bytes pyfixest_tables.tex | 2 +- samplenotebook.ipynb | 6 +++--- statstables/modeltables.py | 6 +++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/main.pdf b/main.pdf index fd53a94fb04802092f6335a28084425bbd7cca40..df3530c059e41f1d2d22572ff2a53d323290185d 100644 GIT binary patch delta 485 zcmaF&lJ(h3)(u@UlNWGmZr&`jl{xwFym<;-o&OT%UJP{i6HjSaJ5NIE@Y1=*x1Ve% zFrKoBHDvGFn)y!KF4)x0PLg+2{@o|lF|WU!(X@4UGlS|qWj<%2<2toz@=To3`)WRh@-S^w;Mj7uyJMZR)ZMI{4`xlvGKvjm&3aRR z?$g8H)r<|5Tqgn=92i&CUKD9h>X#|0u@K`{a$U0gp6VlqB}bJe)_zM2*XdSO`t?aq z+sNhR`Nyjw6d&dHS58!Z<=pT7$VJ4Z-o?beHbU5Sqn=lib57T$6#^&w=O;gUb!V$9 zPh4#1Bh9j_9T(eI`~CSAb34VP@a=@@`s>(k?qaxE#}m`fY@XlvuKGcl*baXt{_;9I z(e#x4qGgAb{xK#c$o6oh=A{&u6s0ESf>`-USzN^>iA5z9MX70AmL`^##$2kZuFU>! FTmYyM!~_5U delta 471 zcmaF%lJ(t7)(u@Uj24p@a%ybeEVGq4`z5opa#mb0lk48)jvY>m>?2A_-c5YVDw(j_ zu`R1ev-e|ek@6j$rB@h3-y7avK0(*SUw+jM_X~gab#JY(lViIg@{W&bN=3f~k7vu~ zFK25xRB~pf)ZdjAtDJCwFS5@q!o#I#_rG8KD=r;To17>k+t<>$b2QH6)(DhN6p501D1U$Hk)1mPG7B}09(lzG zUwtee`DkbAROPhzu*yewx9?K4t6R%;q%5h_yUISy_0!ACt6a)%DeTVgyLZX(M;T9e v-}Jf3cS?NX-WEEWoXouc_L-#&!_)0.609\n", "\n", - " nan\n", + " \n", "\n", " \n", " *p<0.1, **p<0.05, ***p<0.01\n", @@ -3609,7 +3609,7 @@ "+ (0.010) +\n", "--------------------------------------------------\n", "+ Observations 997 997 +\n", - "+ R² 0.609 nan +\n", + "+ R² 0.609 +\n", "--------------------------------------------------\n", "*p<0.1, **p<0.05, ***p<0.01 " ] diff --git a/statstables/modeltables.py b/statstables/modeltables.py index c029793..5efd351 100644 --- a/statstables/modeltables.py +++ b/statstables/modeltables.py @@ -1,4 +1,5 @@ # Tables that can be used to export model information +import numpy as np from abc import ABC, abstractmethod from typing import Any from dataclasses import dataclass @@ -151,7 +152,10 @@ def __post_init__(self): def pull_params(self): for info, attr in PYFIXEST_MAP.items(): try: - self.data[info] = getattr(self.model, attr) + val = getattr(self.model, attr) + if np.isnan(val): + val = "" + self.data[info] = val except AttributeError: pass params = self.model.coef()