Skip to content

Commit 68e0687

Browse files
chore: apply PR126 rank-aware fit policy
1 parent d3964e5 commit 68e0687

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
name: PR126 rank-aware fit autofix
2+
3+
on:
4+
push:
5+
branches: [agent/panel-p1-stage-c-covariance]
6+
paths:
7+
- .github/workflows/pr126-autofix-rank-aware-fit.yml
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
autofix:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
with:
18+
ref: agent/panel-p1-stage-c-covariance
19+
fetch-depth: 0
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: '3.11'
23+
- name: Apply rank-aware backward-compatible fit policy
24+
shell: bash
25+
run: |
26+
python - <<'PY'
27+
from pathlib import Path
28+
29+
def replace_once(path, old, new):
30+
p = Path(path)
31+
text = p.read_text()
32+
count = text.count(old)
33+
if count != 1:
34+
raise SystemExit(f"{path}: expected one replacement, found {count}")
35+
p.write_text(text.replace(old, new))
36+
37+
# Pooled: keep historical full-rank paths, SVD only for declared numerical deficiency/failure.
38+
replace_once(
39+
'statgpu/panel/_pooled.py',
40+
'from statgpu.panel._linalg import panel_lstsq\n',
41+
'from statgpu.panel._linalg import panel_lstsq, panel_matrix_rank\n'
42+
)
43+
replace_once(
44+
'statgpu/panel/_pooled.py',
45+
'''def _panel_lstsq(X, y, xp):\n """Return least-squares coefficients and rank under the shared panel policy."""\n return panel_lstsq(X, y, xp)\n''',
46+
'''def _panel_lstsq(X, y, xp):\n """Preserve the historical full-rank solver under an explicit rank policy."""\n rank = panel_matrix_rank(X, xp)\n if rank < int(X.shape[1]):\n params, _ = panel_lstsq(X, y, xp)\n return params, rank\n if getattr(xp, "__name__", "") == "torch":\n try:\n return xp.linalg.pinv(X) @ y, rank\n except RuntimeError:\n params, _ = panel_lstsq(X, y, xp)\n return params, rank\n try:\n return xp.linalg.lstsq(X, y, rcond=None)[0], rank\n except (TypeError, AttributeError, np.linalg.LinAlgError):\n params, _ = panel_lstsq(X, y, xp)\n return params, rank\n'''
47+
)
48+
49+
# PanelOLS: restore historical full-rank Cholesky/solve route.
50+
replace_once(
51+
'statgpu/panel/_fixed_effects.py',
52+
'''from statgpu.backends import (\n _to_float_scalar,\n _to_numpy,\n xp_maximum,\n)\n''',
53+
'''from statgpu.backends import (\n _LINALG_ERRORS,\n _to_float_scalar,\n _to_numpy,\n xp_cholesky_solve,\n xp_maximum,\n)\n'''
54+
)
55+
replace_once(
56+
'statgpu/panel/_fixed_effects.py',
57+
'from statgpu.panel._linalg import panel_lstsq\n',
58+
'from statgpu.panel._linalg import panel_lstsq, panel_matrix_rank\n'
59+
)
60+
replace_once(
61+
'statgpu/panel/_fixed_effects.py',
62+
''' coef, fit_rank = panel_lstsq(X_d, y_d, xp)\n''',
63+
''' fit_rank = panel_matrix_rank(X_d, xp)\n if fit_rank < int(X_d.shape[1]):\n coef, _ = panel_lstsq(X_d, y_d, xp)\n else:\n XtX = X_d.T @ X_d\n Xty = X_d.T @ y_d\n try:\n coef = xp_cholesky_solve(XtX, Xty, xp)\n except _LINALG_ERRORS:\n try:\n coef = xp.linalg.solve(XtX, Xty)\n except _LINALG_ERRORS:\n coef, _ = panel_lstsq(X_d, y_d, xp)\n'''
64+
)
65+
66+
# Between and FirstDifference: rank-aware guard around historical normal-equation solve.
67+
for path, design, response, rank_name in [
68+
('statgpu/panel/_between.py', 'X_mean', 'y_mean', 'rank_mean'),
69+
('statgpu/panel/_first_diff.py', 'X_diff', 'y_diff', 'rank_diff'),
70+
]:
71+
replace_once(
72+
path,
73+
'from statgpu.backends import _to_float_scalar, _to_numpy, xp_asarray\n',
74+
'from statgpu.backends import _LINALG_ERRORS, _to_float_scalar, _to_numpy, xp_asarray\n'
75+
)
76+
replace_once(
77+
path,
78+
'from statgpu.panel._linalg import panel_lstsq\n',
79+
'from statgpu.panel._linalg import panel_lstsq, panel_matrix_rank\n'
80+
)
81+
old = f' params, {rank_name} = panel_lstsq({design}, {response}, xp)\n'
82+
new = f''' {rank_name} = panel_matrix_rank({design}, xp)\n if {rank_name} < int({design}.shape[1]):\n params, _ = panel_lstsq({design}, {response}, xp)\n else:\n XtX = {design}.T @ {design}\n Xty = {design}.T @ {response}\n try:\n params = xp.linalg.solve(XtX, Xty)\n except _LINALG_ERRORS:\n params, _ = panel_lstsq({design}, {response}, xp)\n'''
83+
replace_once(path, old, new)
84+
85+
# RandomEffects: preserve each historical full-rank regression route.
86+
replace_once(
87+
'statgpu/panel/_random_effects.py',
88+
'''from statgpu.backends import (\n _to_float_scalar,\n _to_numpy,\n xp_asarray,\n xp_zeros,\n)\n''',
89+
'''from statgpu.backends import (\n _LINALG_ERRORS,\n _to_float_scalar,\n _to_numpy,\n xp_asarray,\n xp_cholesky_solve,\n xp_zeros,\n)\n'''
90+
)
91+
replace_once(
92+
'statgpu/panel/_random_effects.py',
93+
'from statgpu.panel._linalg import panel_lstsq\n',
94+
'from statgpu.panel._linalg import panel_lstsq, panel_matrix_rank\n'
95+
)
96+
replace_once(
97+
'statgpu/panel/_random_effects.py',
98+
''' beta_between, _rank_between = panel_lstsq(\n X_bar_unique, y_bar_unique, xp\n )\n''',
99+
''' rank_between = panel_matrix_rank(X_bar_unique, xp)\n if rank_between < int(X_bar_unique.shape[1]):\n beta_between, _ = panel_lstsq(X_bar_unique, y_bar_unique, xp)\n else:\n XtX_b = X_bar_unique.T @ X_bar_unique\n Xty_b = X_bar_unique.T @ y_bar_unique\n try:\n beta_between = xp.linalg.solve(XtX_b, Xty_b)\n except _LINALG_ERRORS:\n beta_between, _ = panel_lstsq(X_bar_unique, y_bar_unique, xp)\n'''
100+
)
101+
replace_once(
102+
'statgpu/panel/_random_effects.py',
103+
''' beta_within, _rank_within = panel_lstsq(\n X_within_fit, y_within, xp\n )\n''',
104+
''' rank_within = panel_matrix_rank(X_within_fit, xp)\n if rank_within < int(X_within_fit.shape[1]):\n beta_within, _ = panel_lstsq(X_within_fit, y_within, xp)\n else:\n XtX_w = X_within_fit.T @ X_within_fit\n Xty_w = X_within_fit.T @ y_within\n beta_within = xp.linalg.pinv(XtX_w) @ Xty_w\n'''
105+
)
106+
replace_once(
107+
'statgpu/panel/_random_effects.py',
108+
''' beta_within, _rank_within = panel_lstsq(\n X_within, y_within, xp\n )\n''',
109+
''' rank_within = panel_matrix_rank(X_within, xp)\n if rank_within < int(X_within.shape[1]):\n beta_within, _ = panel_lstsq(X_within, y_within, xp)\n else:\n XtX_w = X_within.T @ X_within\n Xty_w = X_within.T @ y_within\n try:\n beta_within = xp.linalg.solve(XtX_w, Xty_w)\n except _LINALG_ERRORS:\n beta_within, _ = panel_lstsq(X_within, y_within, xp)\n'''
110+
)
111+
replace_once(
112+
'statgpu/panel/_random_effects.py',
113+
''' beta_gls, rank_star = panel_lstsq(X_star, y_star, xp)\n''',
114+
''' rank_star = panel_matrix_rank(X_star, xp)\n if rank_star < int(X_star.shape[1]):\n beta_gls, _ = panel_lstsq(X_star, y_star, xp)\n else:\n XtX_s = X_star.T @ X_star\n Xty_s = X_star.T @ y_star\n try:\n beta_gls = xp_cholesky_solve(XtX_s, Xty_s, xp)\n except _LINALG_ERRORS:\n try:\n beta_gls = xp.linalg.solve(XtX_s, Xty_s)\n except _LINALG_ERRORS:\n beta_gls, _ = panel_lstsq(X_star, y_star, xp)\n'''
115+
)
116+
# The transformed intercept is one-column and full-rank in valid RE fits;
117+
# preserve the old rank-aware result without changing public coefficients.
118+
119+
# Lock the full-rank PanelOLS path: the rank-deficient SVD solve must not be used.
120+
edge = Path('dev/tests/test_panel_stage_c_edge_contracts.py')
121+
text = edge.read_text()
122+
addition = '''\n\ndef test_panel_full_rank_fit_preserves_historical_solver_path(monkeypatch):\n import statgpu.panel._fixed_effects as fixed_effects_module\n from statgpu.panel import PanelOLS\n\n rng = np.random.default_rng(12955)\n X = rng.normal(size=(80, 3))\n y = X @ np.array([0.4, -0.2, 0.7]) + rng.normal(scale=0.1, size=80)\n expected = np.linalg.solve(X.T @ X, X.T @ y)\n\n def _forbid_rank_deficient_solve(*args, **kwargs):\n raise AssertionError("full-rank PanelOLS entered the SVD rank-deficient solve")\n\n monkeypatch.setattr(fixed_effects_module, "panel_lstsq", _forbid_rank_deficient_solve)\n model = PanelOLS().fit(X, y)\n np.testing.assert_allclose(model.coef_, expected, rtol=2e-12, atol=2e-14)\n'''
123+
if 'test_panel_full_rank_fit_preserves_historical_solver_path' not in text:
124+
edge.write_text(text + addition)
125+
else:
126+
raise SystemExit('full-rank historical solver test already exists')
127+
PY
128+
- name: Install validation environment
129+
run: |
130+
python -m pip install --upgrade pip
131+
python -m pip install -e '.[formula,validation]' 'linearmodels==7.0' 'statsmodels==0.14.6' pytest
132+
- name: Run backward-compatibility and Stage-C review matrix
133+
run: |
134+
python -m pytest \
135+
dev/tests/test_panel_stage_c_covariance.py \
136+
dev/tests/test_panel_stage_c_edge_contracts.py \
137+
dev/tests/test_panel_stage_c_external.py \
138+
dev/tests/test_panel_stage_c_external_defaults.py \
139+
dev/tests/test_panel_stage_c_linearmodels_estimators.py \
140+
dev/tests/test_panel_stage_c_api_formula.py \
141+
dev/tests/test_panel_stage_c_physical_runner_contract.py \
142+
dev/tests/test_panel_stage_c_inference_guard.py \
143+
-q --tb=short
144+
python -m py_compile \
145+
statgpu/panel/_linalg.py statgpu/panel/_covariance.py \
146+
statgpu/panel/_pooled.py statgpu/panel/_fixed_effects.py \
147+
statgpu/panel/_between.py statgpu/panel/_first_diff.py \
148+
statgpu/panel/_random_effects.py statgpu/panel/_diagnostics.py \
149+
statgpu/panel/_diagnostic_context.py
150+
git diff --check
151+
- name: Commit rank-aware policy and self-delete
152+
shell: bash
153+
run: |
154+
git config user.name 'TheHiddenObserver'
155+
git config user.email '51812297+TheHiddenObserver@users.noreply.github.com'
156+
git add \
157+
statgpu/panel/_pooled.py statgpu/panel/_fixed_effects.py \
158+
statgpu/panel/_between.py statgpu/panel/_first_diff.py \
159+
statgpu/panel/_random_effects.py \
160+
dev/tests/test_panel_stage_c_edge_contracts.py
161+
git rm -f .github/workflows/pr126-autofix-rank-aware-fit.yml
162+
git commit -m 'fix(panel): preserve full-rank fit solver contracts'
163+
git push origin HEAD:agent/panel-p1-stage-c-covariance

0 commit comments

Comments
 (0)