open-econs 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- open_econs/__init__.py +8 -0
- open_econs/_internal/__init__.py +0 -0
- open_econs/_internal/errors.py +47 -0
- open_econs/_internal/formula.py +47 -0
- open_econs/_version.py +1 -0
- open_econs/core/__init__.py +0 -0
- open_econs/core/base.py +83 -0
- open_econs/core/context.py +54 -0
- open_econs/core/results.py +341 -0
- open_econs/models/__init__.py +0 -0
- open_econs/models/decomposition/__init__.py +0 -0
- open_econs/models/decomposition/oaxaca.py +205 -0
- open_econs/models/linear/__init__.py +0 -0
- open_econs/models/linear/ols.py +238 -0
- open_econs-0.3.0.dist-info/METADATA +21 -0
- open_econs-0.3.0.dist-info/RECORD +18 -0
- open_econs-0.3.0.dist-info/WHEEL +4 -0
- open_econs-0.3.0.dist-info/licenses/LICENSE +21 -0
open_econs/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def missing_column_error(name: str, available: Sequence[str]) -> ValueError:
|
|
5
|
+
available_str = ", ".join(sorted(available))
|
|
6
|
+
return ValueError(
|
|
7
|
+
f"Column '{name}' not found in DataFrame. "
|
|
8
|
+
f"Available columns: [{available_str}]"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cluster_column_error(name: str, available: Sequence[str]) -> ValueError:
|
|
13
|
+
available_str = ", ".join(sorted(available))
|
|
14
|
+
return ValueError(
|
|
15
|
+
f"Cluster column '{name}' not found in DataFrame. "
|
|
16
|
+
f"Available columns: [{available_str}]"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def non_binary_error(name: str, values: Sequence) -> ValueError:
|
|
21
|
+
found = ", ".join(repr(v) for v in values)
|
|
22
|
+
return ValueError(
|
|
23
|
+
f"Column '{name}' must be binary (exactly 2 unique non-null values). "
|
|
24
|
+
f"Found {len(values)} unique values: [{found}]"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def empty_data_error(original_n: int, dropped: int = 0, cols: list[str] | None = None) -> ValueError:
|
|
29
|
+
msg = f"DataFrame has 0 rows after dropping missing values. Started with {original_n} rows"
|
|
30
|
+
if dropped:
|
|
31
|
+
cols_str = ", ".join(cols or ["unknown"])
|
|
32
|
+
msg += f", {dropped} had missing values in: [{cols_str}]"
|
|
33
|
+
msg += "."
|
|
34
|
+
return ValueError(msg)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def singular_matrix_error() -> RuntimeError:
|
|
38
|
+
return RuntimeError(
|
|
39
|
+
"Singular design matrix — check for collinear or duplicated variables in your formula."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rows_dropped_warning(dropped: int, total: int, cols: list[str]) -> str:
|
|
44
|
+
cols_str = ", ".join(sorted(cols))
|
|
45
|
+
return (
|
|
46
|
+
f"Dropped {dropped} of {total} rows due to missing values in: [{cols_str}]"
|
|
47
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from formulaic import Formula
|
|
5
|
+
|
|
6
|
+
from open_econs._internal.errors import (
|
|
7
|
+
empty_data_error,
|
|
8
|
+
missing_column_error,
|
|
9
|
+
rows_dropped_warning,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_formula(
|
|
14
|
+
formula: str,
|
|
15
|
+
data: pd.DataFrame,
|
|
16
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
17
|
+
_validate_columns_in_data(formula, data)
|
|
18
|
+
|
|
19
|
+
original_n = len(data)
|
|
20
|
+
result = Formula(formula).get_model_matrix(data, na_action="drop")
|
|
21
|
+
yy: pd.DataFrame = result.lhs
|
|
22
|
+
XX: pd.DataFrame = result.rhs
|
|
23
|
+
|
|
24
|
+
dropped = original_n - len(yy)
|
|
25
|
+
if dropped > 0:
|
|
26
|
+
cols = _find_missing_cols(formula, data)
|
|
27
|
+
warnings.warn(rows_dropped_warning(dropped, original_n, cols))
|
|
28
|
+
|
|
29
|
+
if len(yy) == 0:
|
|
30
|
+
cols = _find_missing_cols(formula, data)
|
|
31
|
+
raise empty_data_error(original_n, dropped, cols)
|
|
32
|
+
|
|
33
|
+
return yy, XX
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_columns_in_data(formula: str, data: pd.DataFrame) -> None:
|
|
37
|
+
f = Formula(formula)
|
|
38
|
+
vars_needed: set[str] = {str(v) for v in f.required_variables}
|
|
39
|
+
missing = sorted(v for v in vars_needed if v not in data.columns)
|
|
40
|
+
if missing:
|
|
41
|
+
raise missing_column_error(missing[0], data.columns.tolist())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _find_missing_cols(formula: str, data: pd.DataFrame) -> list[str]:
|
|
45
|
+
f = Formula(formula)
|
|
46
|
+
vars_needed: set[str] = {str(v) for v in f.required_variables}
|
|
47
|
+
return sorted(v for v in vars_needed if data[v].isna().any())
|
open_econs/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
File without changes
|
open_econs/core/base.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
class BaseModel(ABC):
|
|
7
|
+
formula: str = ""
|
|
8
|
+
data_shape: tuple[int, int] = (0, 0)
|
|
9
|
+
cov_type: str = ""
|
|
10
|
+
call: dict[str, Any] = {}
|
|
11
|
+
timestamp: datetime = datetime.fromtimestamp(0)
|
|
12
|
+
package_version: str = ""
|
|
13
|
+
|
|
14
|
+
_frozen: bool = False
|
|
15
|
+
|
|
16
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
17
|
+
if getattr(self, "_frozen", False):
|
|
18
|
+
raise AttributeError(
|
|
19
|
+
f"{type(self).__name__} results are immutable after fit(). "
|
|
20
|
+
f"Cannot set '{name}'. Create a new estimate instead."
|
|
21
|
+
)
|
|
22
|
+
super().__setattr__(name, value)
|
|
23
|
+
|
|
24
|
+
def _freeze(self) -> None:
|
|
25
|
+
object.__setattr__(self, "_frozen", True)
|
|
26
|
+
|
|
27
|
+
# ── abstract interface ──────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def tidy(self) -> pd.DataFrame:
|
|
31
|
+
"""Coefficient or effect table, one row per term (R-broom style)."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def summary(self) -> str:
|
|
35
|
+
"""Pretty-printed terminal string."""
|
|
36
|
+
|
|
37
|
+
# ── optional stubs (loud) ───────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def predict(self, newdata: pd.DataFrame | None = None) -> pd.Series:
|
|
40
|
+
raise NotImplementedError(
|
|
41
|
+
f"{type(self).__name__} does not support predict(). "
|
|
42
|
+
"predict() is only defined for LinearModel-family results."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def plot(self) -> None:
|
|
46
|
+
raise NotImplementedError(
|
|
47
|
+
"plot() is not implemented in this version of open-econs. "
|
|
48
|
+
"Planned for a future release; matplotlib will be an optional "
|
|
49
|
+
"extra, not a hard dependency. In the meantime, call .tidy() "
|
|
50
|
+
"and plot the DataFrame yourself."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def export(self, path: str) -> None:
|
|
54
|
+
import json
|
|
55
|
+
|
|
56
|
+
if path.endswith(".json"):
|
|
57
|
+
data = self.to_dict()
|
|
58
|
+
with open(path, "w") as fh:
|
|
59
|
+
json.dump(data, fh, indent=2, default=str)
|
|
60
|
+
elif path.endswith(".csv"):
|
|
61
|
+
self.tidy().to_csv(path, index=False)
|
|
62
|
+
else:
|
|
63
|
+
raise NotImplementedError(
|
|
64
|
+
f"export() to '{path.rsplit('.', 1)[-1]}' is not supported in "
|
|
65
|
+
"this version. Only .json and .csv export are available. "
|
|
66
|
+
"Export with a '.json' or '.csv' extension, or call .tidy() "
|
|
67
|
+
"and save the DataFrame yourself."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
d = self.tidy().to_dict(orient="records")
|
|
72
|
+
return {
|
|
73
|
+
"formula": self.formula,
|
|
74
|
+
"nobs": self.data_shape[0],
|
|
75
|
+
"cov_type": self.cov_type,
|
|
76
|
+
"call": {k: (str(v) if not isinstance(v, (str, int, float, bool, type(None))) else v) for k, v in self.call.items()},
|
|
77
|
+
"timestamp": str(self.timestamp),
|
|
78
|
+
"package_version": self.package_version,
|
|
79
|
+
"results": d,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def __repr__(self) -> str:
|
|
83
|
+
return self.summary()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Context:
|
|
7
|
+
"""A context that remembers a dataset for repeated estimation.
|
|
8
|
+
|
|
9
|
+
Once created, every estimator method forwards to the top-level
|
|
10
|
+
function with ``data=self.data`` injected automatically.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
data : pd.DataFrame
|
|
15
|
+
The working dataset.
|
|
16
|
+
|
|
17
|
+
Examples
|
|
18
|
+
--------
|
|
19
|
+
>>> import open_econs as oe
|
|
20
|
+
>>> ctx = oe.Context(df)
|
|
21
|
+
>>> r1 = ctx.ols("income ~ education + age")
|
|
22
|
+
>>> r2 = ctx.oaxaca("income ~ education + age + female", by="female")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, data: pd.DataFrame) -> None:
|
|
26
|
+
self._data = data
|
|
27
|
+
|
|
28
|
+
def ols(
|
|
29
|
+
self,
|
|
30
|
+
formula: str,
|
|
31
|
+
cluster: str | None = None,
|
|
32
|
+
cov_type: str = "HC1",
|
|
33
|
+
) -> Any:
|
|
34
|
+
from open_econs.models.linear.ols import ols as _ols
|
|
35
|
+
|
|
36
|
+
return _ols(formula=formula, data=self._data, cluster=cluster, cov_type=cov_type)
|
|
37
|
+
|
|
38
|
+
def oaxaca(
|
|
39
|
+
self,
|
|
40
|
+
formula: str,
|
|
41
|
+
by: str,
|
|
42
|
+
decomposition_type: str = "two-fold",
|
|
43
|
+
) -> Any:
|
|
44
|
+
from open_econs.models.decomposition.oaxaca import oaxaca as _oaxaca
|
|
45
|
+
|
|
46
|
+
return _oaxaca(
|
|
47
|
+
formula=formula,
|
|
48
|
+
data=self._data,
|
|
49
|
+
by=by,
|
|
50
|
+
decomposition_type=decomposition_type,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
return f"Context(data: {self._data.shape[0]} rows, {self._data.shape[1]} cols)"
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from scipy import stats as _stats
|
|
7
|
+
from statsmodels.stats.diagnostic import het_breuschpagan as _het_breuschpagan
|
|
8
|
+
from statsmodels.stats.stattools import durbin_watson as _durbin_watson
|
|
9
|
+
|
|
10
|
+
from open_econs._version import __version__
|
|
11
|
+
from open_econs.core.base import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ramsey_reset(
|
|
15
|
+
fitted: np.ndarray, resid: np.ndarray, power: int = 3,
|
|
16
|
+
) -> tuple[float, float]:
|
|
17
|
+
n = len(resid)
|
|
18
|
+
y_hat_sq = np.column_stack([fitted ** p for p in range(2, power + 1)])
|
|
19
|
+
X_aug = np.column_stack([np.ones(n), fitted, y_hat_sq])
|
|
20
|
+
beta = np.linalg.lstsq(X_aug, fitted + resid, rcond=None)[0]
|
|
21
|
+
pred_aug = X_aug @ beta
|
|
22
|
+
resid_aug = (fitted + resid) - pred_aug
|
|
23
|
+
ssr_r = np.sum(resid ** 2)
|
|
24
|
+
ssr_u = np.sum(resid_aug ** 2)
|
|
25
|
+
k = 1
|
|
26
|
+
m = power - 1
|
|
27
|
+
df_num = m
|
|
28
|
+
df_den = n - k - m - 1
|
|
29
|
+
if df_den <= 0 or ssr_u <= 0:
|
|
30
|
+
return (float("nan"), float("nan"))
|
|
31
|
+
f_stat = ((ssr_r - ssr_u) / m) / (ssr_u / df_den)
|
|
32
|
+
p_val = 1.0 - _stats.f.cdf(f_stat, df_num, df_den)
|
|
33
|
+
return (float(f_stat), float(p_val))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OLSResult(BaseModel):
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
formula: str,
|
|
41
|
+
rhs_formula: str,
|
|
42
|
+
nobs: int,
|
|
43
|
+
df_resid: int,
|
|
44
|
+
df_model: int,
|
|
45
|
+
cov_type: str,
|
|
46
|
+
coefficients: pd.Series,
|
|
47
|
+
std_errors: pd.Series,
|
|
48
|
+
t_stats: pd.Series,
|
|
49
|
+
p_values: pd.Series,
|
|
50
|
+
conf_int: pd.DataFrame,
|
|
51
|
+
r_squared: float,
|
|
52
|
+
adj_r_squared: float,
|
|
53
|
+
f_statistic: float,
|
|
54
|
+
f_p_value: float,
|
|
55
|
+
rsd: float,
|
|
56
|
+
llf: float,
|
|
57
|
+
aic: float,
|
|
58
|
+
bic: float,
|
|
59
|
+
fitted: pd.Series,
|
|
60
|
+
residuals: pd.Series,
|
|
61
|
+
call: dict[str, Any],
|
|
62
|
+
model_spec: Any = None,
|
|
63
|
+
condition_number: float = 0.0,
|
|
64
|
+
_X: pd.DataFrame | None = None,
|
|
65
|
+
_sm_fit: Any = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.formula = formula
|
|
68
|
+
self.rhs_formula = rhs_formula
|
|
69
|
+
self.data_shape = (nobs, coefficients.shape[0])
|
|
70
|
+
self.cov_type = cov_type
|
|
71
|
+
self.call = call
|
|
72
|
+
self.timestamp = datetime.now()
|
|
73
|
+
self.package_version = __version__
|
|
74
|
+
|
|
75
|
+
self.nobs = nobs
|
|
76
|
+
self.df_resid = df_resid
|
|
77
|
+
self.df_model = df_model
|
|
78
|
+
self.coefficients = coefficients
|
|
79
|
+
self.std_errors = std_errors
|
|
80
|
+
self.t_stats = t_stats
|
|
81
|
+
self.p_values = p_values
|
|
82
|
+
self.conf_int = conf_int
|
|
83
|
+
self.r_squared = r_squared
|
|
84
|
+
self.adj_r_squared = adj_r_squared
|
|
85
|
+
self.f_statistic = f_statistic
|
|
86
|
+
self.f_p_value = f_p_value
|
|
87
|
+
self.rsd = rsd
|
|
88
|
+
self.llf = llf
|
|
89
|
+
self.aic = aic
|
|
90
|
+
self.bic = bic
|
|
91
|
+
self.fitted_values = fitted if fitted is not None else pd.Series(dtype=float)
|
|
92
|
+
self.residuals = residuals
|
|
93
|
+
self._model_spec = model_spec
|
|
94
|
+
self.condition_number = condition_number
|
|
95
|
+
self._X = _X
|
|
96
|
+
self._sm_fit = _sm_fit
|
|
97
|
+
|
|
98
|
+
self._freeze()
|
|
99
|
+
|
|
100
|
+
def tidy(self) -> pd.DataFrame:
|
|
101
|
+
df = pd.DataFrame({
|
|
102
|
+
"Variable": self.coefficients.index,
|
|
103
|
+
"Coef": self.coefficients.values,
|
|
104
|
+
"Std Err": self.std_errors.values,
|
|
105
|
+
"t": self.t_stats.values,
|
|
106
|
+
"P>|t|": self.p_values.values,
|
|
107
|
+
"0.025": self.conf_int["lower"].values,
|
|
108
|
+
"0.975": self.conf_int["upper"].values,
|
|
109
|
+
})
|
|
110
|
+
df.index.name = None
|
|
111
|
+
return df
|
|
112
|
+
|
|
113
|
+
def summary(self) -> str:
|
|
114
|
+
llf_str = f"{self.llf:.3f}" if self.llf is not None and not self._isnan(self.llf) else "N/A"
|
|
115
|
+
aic_str = f"{self.aic:.2f}" if self.aic is not None and not self._isnan(self.aic) else "N/A"
|
|
116
|
+
bic_str = f"{self.bic:.2f}" if self.bic is not None and not self._isnan(self.bic) else "N/A"
|
|
117
|
+
header = (
|
|
118
|
+
f" OLS Regression Results \n"
|
|
119
|
+
f"======================================================================\n"
|
|
120
|
+
f"Dep. Variable: {self.formula.split('~')[0].strip()}\n"
|
|
121
|
+
f"No. Observations: {self.nobs}\n"
|
|
122
|
+
f"Df Residuals: {self.df_resid}\n"
|
|
123
|
+
f"Df Model: {self.df_model}\n"
|
|
124
|
+
f"Covariance Type: {self.cov_type}\n"
|
|
125
|
+
f"R-squared: {self.r_squared:.6f}\n"
|
|
126
|
+
f"Adj. R-squared: {self.adj_r_squared:.6f}\n"
|
|
127
|
+
f"Condition No.: {self._fmt(self.condition_number, '.2e')}\n"
|
|
128
|
+
f"F-statistic: {self._fmt(self.f_statistic, '.4f')}\n"
|
|
129
|
+
f"Prob (F-statistic): {self._fmt(self.f_p_value, '.6e')}\n"
|
|
130
|
+
f"Log-Likelihood: {llf_str}\n"
|
|
131
|
+
f"AIC: {aic_str}\n"
|
|
132
|
+
f"BIC: {bic_str}\n"
|
|
133
|
+
f"======================================================================\n"
|
|
134
|
+
)
|
|
135
|
+
tbl = self.tidy().to_string(index=False)
|
|
136
|
+
diag = self.diagnostics()
|
|
137
|
+
diag_lines = []
|
|
138
|
+
try:
|
|
139
|
+
jb_s, jb_p = diag.get("jarque_bera", (float("nan"), float("nan")))
|
|
140
|
+
diag_lines.append(f"Jarque-Bera (chi2={jb_s:.3f}, p={jb_p:.4f})")
|
|
141
|
+
except Exception:
|
|
142
|
+
diag_lines.append("Jarque-Bera: N/A")
|
|
143
|
+
try:
|
|
144
|
+
bp_s, bp_p = diag.get("breusch_pagan", (float("nan"), float("nan")))
|
|
145
|
+
diag_lines.append(f"Breusch-Pagan (LM={bp_s:.3f}, p={bp_p:.4f})")
|
|
146
|
+
except Exception:
|
|
147
|
+
diag_lines.append("Breusch-Pagan: N/A")
|
|
148
|
+
try:
|
|
149
|
+
dw = diag.get("durbin_watson", (float("nan"),))[0]
|
|
150
|
+
diag_lines.append(f"Durbin-Watson: {dw:.4f}")
|
|
151
|
+
except Exception:
|
|
152
|
+
diag_lines.append("Durbin-Watson: N/A")
|
|
153
|
+
try:
|
|
154
|
+
rs_s, rs_p = diag.get("ramsey_reset", (float("nan"), float("nan")))
|
|
155
|
+
diag_lines.append(f"Ramsey RESET (F={rs_s:.3f}, p={rs_p:.4f})")
|
|
156
|
+
except Exception:
|
|
157
|
+
diag_lines.append("Ramsey RESET: N/A")
|
|
158
|
+
diag_str = "\n".join(diag_lines)
|
|
159
|
+
return (
|
|
160
|
+
header + tbl +
|
|
161
|
+
"\n======================================================================\n"
|
|
162
|
+
f"Diagnostics:\n{diag_str}\n"
|
|
163
|
+
"======================================================================\n"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _isnan(self, v: float) -> bool:
|
|
167
|
+
try:
|
|
168
|
+
return np.isnan(v)
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
def _fmt(self, v: float, spec: str) -> str:
|
|
173
|
+
if self._isnan(v):
|
|
174
|
+
return "N/A"
|
|
175
|
+
return f"{v:{spec}}"
|
|
176
|
+
|
|
177
|
+
def diagnostics(self) -> dict[str, tuple[float, float]]:
|
|
178
|
+
res = self.residuals.values.ravel()
|
|
179
|
+
fitted = self.fitted_values.values.ravel()
|
|
180
|
+
results: dict[str, tuple[float, float]] = {}
|
|
181
|
+
from scipy.stats import jarque_bera as _jb
|
|
182
|
+
jb_stat, jb_p = _jb(res)
|
|
183
|
+
results["jarque_bera"] = (float(jb_stat), float(jb_p))
|
|
184
|
+
dw = _durbin_watson(res)
|
|
185
|
+
results["durbin_watson"] = (float(dw), float("nan"))
|
|
186
|
+
if self._X is not None and len(self._X) == len(res):
|
|
187
|
+
X_vals = self._X.values.astype(float)
|
|
188
|
+
bp_stat, bp_p, _, _ = _het_breuschpagan(res, X_vals)
|
|
189
|
+
results["breusch_pagan"] = (float(bp_stat), float(bp_p))
|
|
190
|
+
reset_stat, reset_p = _ramsey_reset(fitted, res, power=3)
|
|
191
|
+
results["ramsey_reset"] = (float(reset_stat), float(reset_p))
|
|
192
|
+
return results
|
|
193
|
+
|
|
194
|
+
def wald_test(self, r_matrix: Any) -> Any:
|
|
195
|
+
if self._sm_fit is None:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
"No fitted statsmodels result stored. "
|
|
198
|
+
"wald_test() is only available when ols() is used directly."
|
|
199
|
+
)
|
|
200
|
+
return self._sm_fit.wald_test(r_matrix)
|
|
201
|
+
|
|
202
|
+
def f_test(self, r_matrix: Any) -> Any:
|
|
203
|
+
if self._sm_fit is None:
|
|
204
|
+
raise RuntimeError(
|
|
205
|
+
"No fitted statsmodels result stored. "
|
|
206
|
+
"f_test() is only available when ols() is used directly."
|
|
207
|
+
)
|
|
208
|
+
return self._sm_fit.f_test(r_matrix)
|
|
209
|
+
|
|
210
|
+
def predict(self, newdata: pd.DataFrame | None = None) -> pd.Series:
|
|
211
|
+
if newdata is None:
|
|
212
|
+
return self.fitted_values
|
|
213
|
+
try:
|
|
214
|
+
if self._model_spec is not None:
|
|
215
|
+
matrices = self._model_spec.get_model_matrix(newdata, na_action="drop")
|
|
216
|
+
if hasattr(matrices, "rhs"):
|
|
217
|
+
XX = matrices.rhs
|
|
218
|
+
else:
|
|
219
|
+
XX = matrices
|
|
220
|
+
else:
|
|
221
|
+
from formulaic import Formula
|
|
222
|
+
matrices = Formula(self.rhs_formula).get_model_matrix(newdata, na_action="drop")
|
|
223
|
+
XX = matrices.rhs if hasattr(matrices, "rhs") else matrices
|
|
224
|
+
except Exception as e:
|
|
225
|
+
msg = str(e)
|
|
226
|
+
if "not present in the dataset" in msg or "is not present" in msg:
|
|
227
|
+
import re as _re
|
|
228
|
+
m = _re.search(r"`(\w+)`", msg)
|
|
229
|
+
bad_col = m.group(1) if m else self.rhs_formula
|
|
230
|
+
from open_econs._internal.errors import missing_column_error
|
|
231
|
+
raise missing_column_error(bad_col, newdata.columns.tolist()) from e
|
|
232
|
+
raise
|
|
233
|
+
pred = pd.Series(
|
|
234
|
+
np.dot(XX.values, self.coefficients.values),
|
|
235
|
+
index=XX.index,
|
|
236
|
+
name="predicted",
|
|
237
|
+
)
|
|
238
|
+
return pred
|
|
239
|
+
|
|
240
|
+
def plot(self) -> None:
|
|
241
|
+
try:
|
|
242
|
+
import matplotlib.pyplot as plt
|
|
243
|
+
except ImportError:
|
|
244
|
+
raise ImportError(
|
|
245
|
+
"plot() requires matplotlib. Install it with: "
|
|
246
|
+
"pip install open-econs[plot] or pip install matplotlib"
|
|
247
|
+
)
|
|
248
|
+
res = self.residuals.values.ravel()
|
|
249
|
+
fitted = self.fitted_values.values.ravel()
|
|
250
|
+
fig, axes = plt.subplots(2, 2, figsize=(10, 8))
|
|
251
|
+
ax1, ax2, ax3, ax4 = axes[0, 0], axes[0, 1], axes[1, 0], axes[1, 1]
|
|
252
|
+
ax1.scatter(fitted, res, alpha=0.5, s=20)
|
|
253
|
+
ax1.axhline(0, color="red", linestyle="--", linewidth=1)
|
|
254
|
+
ax1.set_xlabel("Fitted values")
|
|
255
|
+
ax1.set_ylabel("Residuals")
|
|
256
|
+
ax1.set_title("Residuals vs Fitted")
|
|
257
|
+
from scipy.stats import probplot
|
|
258
|
+
probplot(res, dist="norm", plot=ax2)
|
|
259
|
+
ax2.get_lines()[0].set_marker("o")
|
|
260
|
+
ax2.get_lines()[0].set_markersize(3)
|
|
261
|
+
ax2.get_lines()[0].set_alpha(0.5)
|
|
262
|
+
ax2.set_title("Normal Q-Q")
|
|
263
|
+
sqrt_abs_res = np.sqrt(np.abs(res))
|
|
264
|
+
ax3.scatter(fitted, sqrt_abs_res, alpha=0.5, s=20)
|
|
265
|
+
ax3.set_xlabel("Fitted values")
|
|
266
|
+
ax3.set_ylabel("Sqrt(|Residuals|)")
|
|
267
|
+
ax3.set_title("Scale-Location")
|
|
268
|
+
ax4.text(0.5, 0.5, "Leverage plot: planned for v0.4", ha="center", va="center", transform=ax4.transAxes)
|
|
269
|
+
ax4.set_title("Residuals vs Leverage")
|
|
270
|
+
fig.tight_layout()
|
|
271
|
+
plt.show()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class OaxacaResult(BaseModel):
|
|
275
|
+
def __init__(
|
|
276
|
+
self,
|
|
277
|
+
*,
|
|
278
|
+
formula: str,
|
|
279
|
+
nobs: int,
|
|
280
|
+
n_params: int,
|
|
281
|
+
cov_type: str,
|
|
282
|
+
explained: float,
|
|
283
|
+
unexplained: float,
|
|
284
|
+
interaction: float | None,
|
|
285
|
+
total_gap: float,
|
|
286
|
+
decomposition_type: str,
|
|
287
|
+
by_groups: tuple[str, str],
|
|
288
|
+
std: pd.Series | None,
|
|
289
|
+
call: dict[str, Any],
|
|
290
|
+
variable_detail: pd.DataFrame | None = None,
|
|
291
|
+
) -> None:
|
|
292
|
+
self.formula = formula
|
|
293
|
+
self.data_shape = (nobs, n_params)
|
|
294
|
+
self.cov_type = cov_type
|
|
295
|
+
self.call = call
|
|
296
|
+
self.timestamp = datetime.now()
|
|
297
|
+
self.package_version = __version__
|
|
298
|
+
|
|
299
|
+
self.nobs = nobs
|
|
300
|
+
self.explained = explained
|
|
301
|
+
self.unexplained = unexplained
|
|
302
|
+
self.interaction = interaction if interaction is not None else 0.0
|
|
303
|
+
self.total_gap = total_gap
|
|
304
|
+
self.type = decomposition_type
|
|
305
|
+
self.by_groups = by_groups
|
|
306
|
+
self.std = std
|
|
307
|
+
self.variable_detail = variable_detail if variable_detail is not None else pd.DataFrame()
|
|
308
|
+
|
|
309
|
+
self._freeze()
|
|
310
|
+
|
|
311
|
+
def tidy(self, detail: bool = False) -> pd.DataFrame:
|
|
312
|
+
if detail and not self.variable_detail.empty:
|
|
313
|
+
return self.variable_detail
|
|
314
|
+
if self.type == "two-fold":
|
|
315
|
+
data = {
|
|
316
|
+
"Component": ["Explained", "Unexplained", "Total Gap"],
|
|
317
|
+
"Effect": [self.explained, self.unexplained, self.total_gap],
|
|
318
|
+
}
|
|
319
|
+
else:
|
|
320
|
+
data = {
|
|
321
|
+
"Component": ["Endowment", "Coefficients", "Interaction", "Total Gap"],
|
|
322
|
+
"Effect": [self.explained, self.unexplained, self.interaction, self.total_gap],
|
|
323
|
+
}
|
|
324
|
+
df = pd.DataFrame(data)
|
|
325
|
+
if self.std is not None:
|
|
326
|
+
std_vals = list(self.std) + [float("nan")]
|
|
327
|
+
df["Std Err"] = std_vals[: len(df)]
|
|
328
|
+
return df
|
|
329
|
+
|
|
330
|
+
def summary(self) -> str:
|
|
331
|
+
header = (
|
|
332
|
+
f" Oaxaca-Blinder Decomposition \n"
|
|
333
|
+
f"======================================================================\n"
|
|
334
|
+
f"Formula: {self.formula}\n"
|
|
335
|
+
f"No. Observations: {self.nobs}\n"
|
|
336
|
+
f"Groups: {self.by_groups[0]} vs {self.by_groups[1]}\n"
|
|
337
|
+
f"Type: {self.type}\n"
|
|
338
|
+
f"======================================================================\n"
|
|
339
|
+
)
|
|
340
|
+
tbl = self.tidy().to_string(index=False)
|
|
341
|
+
return header + tbl + "\n======================================================================\n"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from statsmodels.stats.oaxaca import OaxacaBlinder
|
|
7
|
+
|
|
8
|
+
from open_econs._version import __version__
|
|
9
|
+
from open_econs._internal import errors
|
|
10
|
+
from open_econs._internal.formula import parse_formula
|
|
11
|
+
from open_econs.core.results import OaxacaResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def oaxaca(
|
|
15
|
+
formula: str,
|
|
16
|
+
data: pd.DataFrame,
|
|
17
|
+
by: str,
|
|
18
|
+
decomposition_type: str = "two-fold",
|
|
19
|
+
std: bool = False,
|
|
20
|
+
bootstrap_n: int = 1000,
|
|
21
|
+
conf_level: float = 0.95,
|
|
22
|
+
seed: int | None = None,
|
|
23
|
+
swap: bool = True,
|
|
24
|
+
) -> OaxacaResult:
|
|
25
|
+
"""Perform an Oaxaca-Blinder decomposition.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
formula : str
|
|
30
|
+
Two-sided formula string, e.g. ``"income ~ education + age + female"``.
|
|
31
|
+
``by`` must appear on the RHS of the formula.
|
|
32
|
+
data : pd.DataFrame
|
|
33
|
+
Data containing all variables referenced in *formula*.
|
|
34
|
+
by : str
|
|
35
|
+
Column name of the binary group indicator. Must appear in the RHS
|
|
36
|
+
of *formula*.
|
|
37
|
+
decomposition_type : str, default "two-fold"
|
|
38
|
+
Either ``"two-fold"`` or ``"three-fold"``.
|
|
39
|
+
std : bool, default False
|
|
40
|
+
If True, compute bootstrapped standard errors. Bootstrap is
|
|
41
|
+
computationally expensive; 500 iterations is a reasonable minimum
|
|
42
|
+
for exploration, 1000+ for publication.
|
|
43
|
+
bootstrap_n : int, default 1000
|
|
44
|
+
Number of bootstrap replications (only used when ``std=True``).
|
|
45
|
+
Statsmodels' own default is 5000; 1000 is a reasonable trade-off
|
|
46
|
+
for exploration, 5000+ for publication-grade estimates.
|
|
47
|
+
conf_level : float, default 0.95
|
|
48
|
+
Confidence level for trimming extreme bootstrap draws.
|
|
49
|
+
seed : int, optional
|
|
50
|
+
Random seed for reproducible bootstrap standard errors.
|
|
51
|
+
swap : bool, default True
|
|
52
|
+
If True, the decomposition is computed as group 1 minus group 0
|
|
53
|
+
(group 1 is the higher value of the binary ``by`` column). Setting
|
|
54
|
+
``swap=False`` reverses this.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
OaxacaResult
|
|
59
|
+
Immutable result object with ``.explained``, ``.unexplained``,
|
|
60
|
+
``.total_gap``, and ``.tidy()``.
|
|
61
|
+
|
|
62
|
+
Examples
|
|
63
|
+
--------
|
|
64
|
+
>>> import open_econs as oe
|
|
65
|
+
>>> result = oe.oaxaca("income ~ education + age + female", data=df, by="female")
|
|
66
|
+
>>> result.tidy()
|
|
67
|
+
"""
|
|
68
|
+
if by not in data.columns:
|
|
69
|
+
raise errors.missing_column_error(by, data.columns.tolist())
|
|
70
|
+
|
|
71
|
+
unique_vals = data[by].dropna().unique()
|
|
72
|
+
if len(unique_vals) != 2:
|
|
73
|
+
raise errors.non_binary_error(by, unique_vals)
|
|
74
|
+
|
|
75
|
+
call = _capture_call(
|
|
76
|
+
formula=formula, by=by, decomposition_type=decomposition_type,
|
|
77
|
+
std=std, bootstrap_n=bootstrap_n, conf_level=conf_level, seed=seed,
|
|
78
|
+
swap=swap,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
yy, XX = parse_formula(formula, data)
|
|
82
|
+
y_arr = yy.values.ravel()
|
|
83
|
+
|
|
84
|
+
if by in XX.columns:
|
|
85
|
+
bifurcate_idx = list(XX.columns).index(by)
|
|
86
|
+
else:
|
|
87
|
+
patterns = [f"C({by})[", f"{by}["]
|
|
88
|
+
encoded_cols = [
|
|
89
|
+
c for c in XX.columns
|
|
90
|
+
if any(c.startswith(p) for p in patterns)
|
|
91
|
+
]
|
|
92
|
+
if encoded_cols:
|
|
93
|
+
from pandas import get_dummies
|
|
94
|
+
dummies = get_dummies(data.loc[XX.index, by], prefix=by, drop_first=True)
|
|
95
|
+
if len(dummies.columns) == 1:
|
|
96
|
+
XX = XX.copy()
|
|
97
|
+
for c in encoded_cols:
|
|
98
|
+
XX.drop(columns=[c], inplace=True)
|
|
99
|
+
cname = str(by)
|
|
100
|
+
XX.insert(XX.shape[1], cname, dummies.iloc[:, 0].values.astype(float))
|
|
101
|
+
bifurcate_idx = list(XX.columns).index(cname)
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"The 'by' column '{by}' has {len(dummies.columns) + 1} unique "
|
|
105
|
+
f"values (must be exactly 2 for Oaxaca decomposition)."
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Column '{by}' not found in the design matrix. Ensure '{by}' "
|
|
110
|
+
f"appears on the RHS of the formula (e.g. '{formula}'). "
|
|
111
|
+
f"Available columns in design matrix: {list(XX.columns)}"
|
|
112
|
+
)
|
|
113
|
+
hasconst = any("Intercept" in c for c in XX.columns)
|
|
114
|
+
|
|
115
|
+
if seed is not None:
|
|
116
|
+
np.random.seed(seed)
|
|
117
|
+
|
|
118
|
+
model = OaxacaBlinder(
|
|
119
|
+
y_arr,
|
|
120
|
+
XX.values,
|
|
121
|
+
bifurcate=bifurcate_idx,
|
|
122
|
+
hasconst=hasconst,
|
|
123
|
+
swap=swap,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if decomposition_type == "two-fold":
|
|
127
|
+
stats_result = model.two_fold(std=std, n=bootstrap_n, conf=conf_level)
|
|
128
|
+
explained = float(stats_result.params[1])
|
|
129
|
+
unexplained = float(stats_result.params[0])
|
|
130
|
+
gap_val = float(stats_result.params[2])
|
|
131
|
+
interaction = None
|
|
132
|
+
elif decomposition_type == "three-fold":
|
|
133
|
+
stats_result = model.three_fold(std=std, n=bootstrap_n, conf=conf_level)
|
|
134
|
+
endowment = float(stats_result.params[0])
|
|
135
|
+
coefficients = float(stats_result.params[1])
|
|
136
|
+
interaction = float(stats_result.params[2])
|
|
137
|
+
gap_val = float(stats_result.params[3])
|
|
138
|
+
explained = endowment
|
|
139
|
+
unexplained = coefficients
|
|
140
|
+
else:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Unknown decomposition_type '{decomposition_type}'. "
|
|
143
|
+
f"Use 'two-fold' or 'three-fold'."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
var_names = [c for c in XX.columns if c != by]
|
|
147
|
+
f_params = model._f_model.params
|
|
148
|
+
s_params = model._s_model.params
|
|
149
|
+
f_mean = model.exog_f_mean
|
|
150
|
+
s_mean = model.exog_s_mean
|
|
151
|
+
|
|
152
|
+
if decomposition_type == "two-fold":
|
|
153
|
+
ref_params = model.t_params
|
|
154
|
+
endow_vec = (f_mean - s_mean) * ref_params
|
|
155
|
+
unexpl_vec = f_mean * (f_params - ref_params) + s_mean * (ref_params - s_params)
|
|
156
|
+
var_detail = pd.DataFrame({
|
|
157
|
+
"Variable": var_names,
|
|
158
|
+
"Explained": endow_vec[:len(var_names)],
|
|
159
|
+
"Unexplained": unexpl_vec[:len(var_names)],
|
|
160
|
+
})
|
|
161
|
+
else:
|
|
162
|
+
endow_vec = (f_mean - s_mean) * s_params
|
|
163
|
+
coeff_vec = s_mean * (f_params - s_params)
|
|
164
|
+
inter_vec = (f_mean - s_mean) * (f_params - s_params)
|
|
165
|
+
var_detail = pd.DataFrame({
|
|
166
|
+
"Variable": var_names,
|
|
167
|
+
"Endowment": endow_vec[:len(var_names)],
|
|
168
|
+
"Coefficients": coeff_vec[:len(var_names)],
|
|
169
|
+
"Interaction": inter_vec[:len(var_names)],
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
unique_vals_sorted = sorted(data[by].dropna().unique(), key=str)
|
|
173
|
+
idx_map = {float(i): str(v) for i, v in enumerate(unique_vals_sorted)}
|
|
174
|
+
group_labels = (idx_map.get(model.bi[0], str(model.bi[0])),
|
|
175
|
+
idx_map.get(model.bi[1], str(model.bi[1])))
|
|
176
|
+
|
|
177
|
+
std_series: pd.Series | None = None
|
|
178
|
+
if std and stats_result.std:
|
|
179
|
+
if decomposition_type == "two-fold":
|
|
180
|
+
labels = ["Unexplained", "Explained"]
|
|
181
|
+
else:
|
|
182
|
+
labels = ["Endowment", "Coefficients", "Interaction"]
|
|
183
|
+
std_series = pd.Series(stats_result.std, index=labels, name="std_err")
|
|
184
|
+
|
|
185
|
+
return OaxacaResult(
|
|
186
|
+
formula=formula,
|
|
187
|
+
nobs=int(len(yy)),
|
|
188
|
+
n_params=XX.shape[1],
|
|
189
|
+
cov_type="nonrobust",
|
|
190
|
+
explained=explained,
|
|
191
|
+
unexplained=unexplained,
|
|
192
|
+
interaction=interaction,
|
|
193
|
+
total_gap=gap_val,
|
|
194
|
+
decomposition_type=decomposition_type,
|
|
195
|
+
by_groups=group_labels,
|
|
196
|
+
std=std_series,
|
|
197
|
+
call=call,
|
|
198
|
+
variable_detail=var_detail,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _capture_call(**kwargs: Any) -> dict[str, Any]:
|
|
203
|
+
kwargs["timestamp"] = str(datetime.now())
|
|
204
|
+
kwargs["package_version"] = __version__
|
|
205
|
+
return kwargs
|
|
File without changes
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import statsmodels.api as sm
|
|
7
|
+
|
|
8
|
+
from open_econs._version import __version__
|
|
9
|
+
from open_econs._internal import errors
|
|
10
|
+
from open_econs.core.results import OLSResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ols(
|
|
14
|
+
formula: str,
|
|
15
|
+
data: pd.DataFrame,
|
|
16
|
+
cluster: str | None = None,
|
|
17
|
+
cov_type: str = "HC2",
|
|
18
|
+
weights: str | np.ndarray | pd.Series | None = None,
|
|
19
|
+
) -> OLSResult:
|
|
20
|
+
"""Estimate an ordinary least-squares or weighted least-squares regression.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
formula : str
|
|
25
|
+
Two-sided formula string, e.g. ``"income ~ education + age"``.
|
|
26
|
+
data : pd.DataFrame
|
|
27
|
+
Data containing all variables referenced in *formula*.
|
|
28
|
+
cluster : str, optional
|
|
29
|
+
Column name for cluster-robust standard errors.
|
|
30
|
+
cov_type : str, default "HC2"
|
|
31
|
+
Covariance estimator type. Common choices: ``"HC2"`` (default,
|
|
32
|
+
matches modern Stata ``reg, robust``; changed from HC1 in v0.2.0),
|
|
33
|
+
``"HC1`` (classic White SE), ``"HC0"``, ``"HC3"``, ``"nonrobust"``.
|
|
34
|
+
Ignored when *cluster* is provided (cluster-robust is used instead).
|
|
35
|
+
weights : str or array-like, optional
|
|
36
|
+
Frequency/analytic weights for weighted least squares. If a string,
|
|
37
|
+
interpreted as a column name in *data*. If an array, must match the
|
|
38
|
+
number of rows in *data*.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
OLSResult
|
|
43
|
+
Immutable result object with named coefficient arrays.
|
|
44
|
+
|
|
45
|
+
Examples
|
|
46
|
+
--------
|
|
47
|
+
>>> import open_econs as oe
|
|
48
|
+
>>> result = oe.ols("income ~ education + age", data=df)
|
|
49
|
+
>>> result.tidy()
|
|
50
|
+
>>> result.coefficients["education"]
|
|
51
|
+
"""
|
|
52
|
+
call = _capture_call(formula=formula, cluster=cluster, cov_type=cov_type, weights=weights)
|
|
53
|
+
rhs_formula = formula.split("~", 1)[1].strip()
|
|
54
|
+
|
|
55
|
+
from formulaic import Formula
|
|
56
|
+
try:
|
|
57
|
+
formula_obj = Formula(formula)
|
|
58
|
+
model_spec = formula_obj.get_model_matrix(data, na_action="drop")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
msg = str(e)
|
|
61
|
+
if "not present in the dataset" in msg or "is not present" in msg:
|
|
62
|
+
import re as _re
|
|
63
|
+
m = _re.search(r"`(\w+)`", msg)
|
|
64
|
+
bad_col = m.group(1) if m else formula.split("~", 1)[1].strip()
|
|
65
|
+
raise errors.missing_column_error(bad_col, data.columns.tolist()) from e
|
|
66
|
+
raise
|
|
67
|
+
if hasattr(model_spec, "rhs"):
|
|
68
|
+
XX = model_spec.rhs
|
|
69
|
+
yy = model_spec.lhs
|
|
70
|
+
else:
|
|
71
|
+
from open_econs._internal.formula import parse_formula as _parse
|
|
72
|
+
yy, XX = _parse(formula, data)
|
|
73
|
+
model_spec = None
|
|
74
|
+
|
|
75
|
+
original_n = len(data)
|
|
76
|
+
dropped = original_n - len(yy)
|
|
77
|
+
vars_needed = {str(v) for v in formula_obj.required_variables}
|
|
78
|
+
cols_with_nas = sorted(v for v in vars_needed if v in data.columns and data[v].isna().any())
|
|
79
|
+
if dropped > 0:
|
|
80
|
+
from open_econs._internal.errors import rows_dropped_warning
|
|
81
|
+
import warnings as _w
|
|
82
|
+
_w.warn(rows_dropped_warning(dropped, original_n, cols_with_nas), RuntimeWarning, stacklevel=3)
|
|
83
|
+
|
|
84
|
+
if len(yy) == 0:
|
|
85
|
+
from open_econs._internal.errors import empty_data_error
|
|
86
|
+
raise empty_data_error(original_n, dropped, cols_with_nas)
|
|
87
|
+
|
|
88
|
+
y_arr = yy.values.ravel()
|
|
89
|
+
|
|
90
|
+
if model_spec is not None:
|
|
91
|
+
stored_spec = model_spec.model_spec.rhs
|
|
92
|
+
else:
|
|
93
|
+
stored_spec = None
|
|
94
|
+
|
|
95
|
+
condition_number = _check_collinearity(XX)
|
|
96
|
+
|
|
97
|
+
if weights is not None:
|
|
98
|
+
if isinstance(weights, str):
|
|
99
|
+
if weights not in data.columns:
|
|
100
|
+
raise errors.missing_column_error(weights, data.columns.tolist())
|
|
101
|
+
w_arr = data.loc[XX.index, weights].values.astype(float)
|
|
102
|
+
else:
|
|
103
|
+
w_arr = np.asarray(weights, dtype=float)
|
|
104
|
+
if len(w_arr) != original_n:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"weights array length ({len(w_arr)}) does not match data rows ({original_n})"
|
|
107
|
+
)
|
|
108
|
+
w_arr = w_arr[XX.index]
|
|
109
|
+
if np.any(w_arr < 0):
|
|
110
|
+
raise ValueError("Weights must be non-negative.")
|
|
111
|
+
fitted = sm.WLS(y_arr, XX.values, weights=w_arr).fit(
|
|
112
|
+
cov_type="cluster" if cluster else cov_type,
|
|
113
|
+
cov_kwds={"groups": data.loc[XX.index, cluster]} if cluster else {},
|
|
114
|
+
)
|
|
115
|
+
cov_label = f"cluster({cluster})" if cluster else cov_type
|
|
116
|
+
else:
|
|
117
|
+
if cluster is not None:
|
|
118
|
+
if cluster not in data.columns:
|
|
119
|
+
raise errors.cluster_column_error(cluster, data.columns.tolist())
|
|
120
|
+
aligned_groups = data.loc[XX.index, cluster]
|
|
121
|
+
fitted = sm.OLS(y_arr, XX.values).fit(
|
|
122
|
+
cov_type="cluster",
|
|
123
|
+
cov_kwds={"groups": aligned_groups},
|
|
124
|
+
)
|
|
125
|
+
cov_label = f"cluster({cluster})"
|
|
126
|
+
else:
|
|
127
|
+
fitted = sm.OLS(y_arr, XX.values).fit(cov_type=cov_type)
|
|
128
|
+
cov_label = cov_type
|
|
129
|
+
|
|
130
|
+
coef_arr = fitted.params
|
|
131
|
+
se_arr = fitted.bse
|
|
132
|
+
t_arr = fitted.tvalues
|
|
133
|
+
p_arr = fitted.pvalues
|
|
134
|
+
conf_arr = fitted.conf_int()
|
|
135
|
+
|
|
136
|
+
conf_int = pd.DataFrame(
|
|
137
|
+
{"lower": conf_arr[:, 0], "upper": conf_arr[:, 1]},
|
|
138
|
+
index=XX.columns,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
fitted_values = pd.Series(fitted.fittedvalues, index=XX.index, name="fitted")
|
|
142
|
+
residuals = pd.Series(fitted.resid, index=XX.index, name="residuals")
|
|
143
|
+
|
|
144
|
+
f_stat = _safe_fvalue(fitted)
|
|
145
|
+
f_pval = _safe_f_pvalue(fitted)
|
|
146
|
+
|
|
147
|
+
return OLSResult(
|
|
148
|
+
formula=formula,
|
|
149
|
+
rhs_formula=rhs_formula,
|
|
150
|
+
nobs=int(fitted.nobs),
|
|
151
|
+
df_resid=int(fitted.df_resid),
|
|
152
|
+
df_model=int(fitted.df_model),
|
|
153
|
+
cov_type=cov_label,
|
|
154
|
+
coefficients=pd.Series(coef_arr, index=XX.columns),
|
|
155
|
+
std_errors=pd.Series(se_arr, index=XX.columns),
|
|
156
|
+
t_stats=pd.Series(t_arr, index=XX.columns),
|
|
157
|
+
p_values=pd.Series(p_arr, index=XX.columns),
|
|
158
|
+
conf_int=conf_int,
|
|
159
|
+
r_squared=float(fitted.rsquared),
|
|
160
|
+
adj_r_squared=float(fitted.rsquared_adj),
|
|
161
|
+
f_statistic=f_stat,
|
|
162
|
+
f_p_value=f_pval,
|
|
163
|
+
rsd=float(np.sqrt(fitted.mse_resid)),
|
|
164
|
+
llf=_safe_llf(fitted),
|
|
165
|
+
aic=_safe_aic(fitted),
|
|
166
|
+
bic=_safe_bic(fitted),
|
|
167
|
+
fitted=fitted_values,
|
|
168
|
+
residuals=residuals,
|
|
169
|
+
call=call,
|
|
170
|
+
model_spec=stored_spec,
|
|
171
|
+
condition_number=condition_number,
|
|
172
|
+
_X=XX,
|
|
173
|
+
_sm_fit=fitted,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
reg = ols
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _capture_call(**kwargs: Any) -> dict[str, Any]:
|
|
181
|
+
kwargs["timestamp"] = str(datetime.now())
|
|
182
|
+
kwargs["package_version"] = __version__
|
|
183
|
+
return kwargs
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _safe_fvalue(fitted: Any) -> float:
|
|
187
|
+
try:
|
|
188
|
+
return float(fitted.fvalue)
|
|
189
|
+
except (ValueError, AttributeError):
|
|
190
|
+
return float("nan")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _safe_f_pvalue(fitted: Any) -> float:
|
|
194
|
+
try:
|
|
195
|
+
return float(fitted.f_pvalue)
|
|
196
|
+
except (ValueError, AttributeError):
|
|
197
|
+
return float("nan")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _safe_llf(fitted: Any) -> float:
|
|
201
|
+
try:
|
|
202
|
+
return float(fitted.llf)
|
|
203
|
+
except (ValueError, AttributeError):
|
|
204
|
+
return float("nan")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _safe_aic(fitted: Any) -> float:
|
|
208
|
+
try:
|
|
209
|
+
return float(fitted.aic)
|
|
210
|
+
except (ValueError, AttributeError):
|
|
211
|
+
return float("nan")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _safe_bic(fitted: Any) -> float:
|
|
215
|
+
try:
|
|
216
|
+
return float(fitted.bic)
|
|
217
|
+
except (ValueError, AttributeError):
|
|
218
|
+
return float("nan")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _check_collinearity(XX: pd.DataFrame) -> float:
|
|
222
|
+
from numpy.linalg import cond, matrix_rank
|
|
223
|
+
X_vals = XX.values
|
|
224
|
+
n_params = X_vals.shape[1]
|
|
225
|
+
rank = matrix_rank(X_vals)
|
|
226
|
+
if rank < n_params:
|
|
227
|
+
raise errors.singular_matrix_error()
|
|
228
|
+
cn = float(cond(X_vals))
|
|
229
|
+
if cn > 30:
|
|
230
|
+
import warnings as _w
|
|
231
|
+
_w.warn(
|
|
232
|
+
f"Design matrix is near-singular (condition number = {cn:.2e}). "
|
|
233
|
+
"Belsley, Kuh & Welsch (1980) recommend caution above 30. "
|
|
234
|
+
"Consider removing collinear predictors.",
|
|
235
|
+
RuntimeWarning,
|
|
236
|
+
stacklevel=3,
|
|
237
|
+
)
|
|
238
|
+
return cn
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: open-econs
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: The scikit-learn of empirical economics.
|
|
5
|
+
Project-URL: Homepage, https://github.com/anomalyco/open-econs
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: formulaic>=1.0
|
|
9
|
+
Requires-Dist: numpy>=1.26
|
|
10
|
+
Requires-Dist: pandas>=2.2
|
|
11
|
+
Requires-Dist: scipy>=1.11
|
|
12
|
+
Requires-Dist: statsmodels>=0.14
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: hypothesis>=6.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Provides-Extra: lint
|
|
18
|
+
Requires-Dist: mypy>=1.8; extra == 'lint'
|
|
19
|
+
Requires-Dist: ruff>=0.3; extra == 'lint'
|
|
20
|
+
Provides-Extra: plot
|
|
21
|
+
Requires-Dist: matplotlib>=3.8; extra == 'plot'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
open_econs/__init__.py,sha256=7RtocgpChkCnZS3yRtD7Ny2YfHTTQXmBr5iEvA-BN7g,223
|
|
2
|
+
open_econs/_version.py,sha256=5mDtfU2t04ATmg92S5B-scUcdjCy1ZBq620y3GmpdtQ,21
|
|
3
|
+
open_econs/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
open_econs/_internal/errors.py,sha256=zMV2x_t_tBav_L9cqYv08hIFSjqa27Ww0Te846sJCO8,1606
|
|
5
|
+
open_econs/_internal/formula.py,sha256=U8hxWczkJLZHBtTSjTtkE7NJE_BxXsBj4EO3Fi80tO0,1378
|
|
6
|
+
open_econs/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
open_econs/core/base.py,sha256=AmTGlEoyZ5lzNQlo6GuE_GslnX95mo_hRXucPy9xGI4,3144
|
|
8
|
+
open_econs/core/context.py,sha256=6-PycFukd8hGjwK8_g3Mle9d14mVLKta4HK69qONxJ8,1411
|
|
9
|
+
open_econs/core/results.py,sha256=BLzIYTU6Mpcrc-ipNfqIa64a43VD67FzT_hPoAuuorE,13478
|
|
10
|
+
open_econs/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
open_econs/models/decomposition/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
open_econs/models/decomposition/oaxaca.py,sha256=9QEVoKdsw2Lbbv6QswPJUfEuo6oe27FEGmHZ6GF8rS8,7411
|
|
13
|
+
open_econs/models/linear/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
open_econs/models/linear/ols.py,sha256=Jgjs3u0TlFpBnKaAavLKXB_wTdGQOpmt8gubDs0cLbQ,7851
|
|
15
|
+
open_econs-0.3.0.dist-info/METADATA,sha256=d6lJf-n8d3pQtSPa4pHa5L_vJxPsBDjo9IINfg8EOus,683
|
|
16
|
+
open_econs-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
17
|
+
open_econs-0.3.0.dist-info/licenses/LICENSE,sha256=G29KdzB-lU2HHhxZdhrNvJR_R1yW-NjteextQhiTefU,1065
|
|
18
|
+
open_econs-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 M Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|