|
| 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