Skip to content

Commit 33f57bb

Browse files
committed
update qplot
1 parent a0cb806 commit 33f57bb

File tree

2 files changed

+103
-77
lines changed

2 files changed

+103
-77
lines changed

pyfixest/estimation/quantreg_.py

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,42 @@ def to_array(self):
8989

9090
def get_fit(self) -> None:
9191
"""Fit a quantile regression model using the interior point method."""
92-
self._beta_hat = self.fit_qreg(X=self._X, Y=self._Y, q=self._quantile)
92+
self._beta_hat = self.fit_qreg_fn(X=self._X, Y=self._Y, q=self._quantile)
9393
self._u_hat = self._Y.flatten() - self._X @ self._beta_hat
9494
self._hessian = self._X.T @ self._X
9595
self._bread = np.linalg.inv(self._hessian)
9696

97-
def fit_qreg_ip(
97+
def fit_qreg_fn(self, X: np.ndarray, Y: np.ndarray, q: float) -> np.ndarray:
98+
"""Fit a quantile regression model using the Frisch-Newton Interior Point Solver."""
99+
N, _ = X.shape
100+
101+
beta_hat, has_converged = frisch_newton_solver(
102+
A=X.T,
103+
b=(1 - q) * X.T @ np.ones(N),
104+
c=-Y,
105+
u=np.ones(N),
106+
q=q,
107+
tol=1e-6,
108+
max_iter=50,
109+
backoff=0.9995,
110+
)
111+
112+
if not has_converged:
113+
warnings.warn(
114+
"The Frisch-Newton Interior Point solver has not converged after 50 iterations."
115+
)
116+
117+
return -beta_hat.flatten()
118+
119+
def fit_qreg_pfn(
98120
self,
99121
X: np.ndarray,
100122
Y: np.ndarray,
101123
q: float,
102124
rng: np.random.Generator,
103125
beta_init: Optional[np.ndarray] = None,
104126
) -> np.ndarray:
105-
"""Fit a quantile regression model using the interior point method."""
127+
"""Fit a quantile regression model using preprocessing and the Frisch-Newton Interior Point Solver."""
106128
N, k = self._X.shape
107129
has_converged = False
108130
compute_beta_init = beta_init is None
@@ -118,7 +140,7 @@ def fit_qreg_ip(
118140
if compute_beta_init:
119141
# get initial sample
120142
idx_init = rng.choice(N, size=n_init, replace=False)
121-
beta_hat_init = self.fit_qreg(X[idx_init, :], Y[idx_init], q=q)
143+
beta_hat_init = self.fit_qreg_fn(X[idx_init, :], Y[idx_init], q=q)
122144

123145
else:
124146
beta_hat_init = beta_init
@@ -151,7 +173,7 @@ def fit_qreg_ip(
151173

152174
while not has_converged and n_bad_fixups < max_bad_fixups:
153175
# solve the modified problem
154-
beta_hat = self.fit_qreg(X=X_sub, Y=Y_sub, q=q)
176+
beta_hat = self.fit_qreg_fn(X=X_sub, Y=Y_sub, q=q)
155177
r = Y.flatten() - X @ beta_hat
156178

157179
# count wrong predictions and get their indices
@@ -173,28 +195,6 @@ def fit_qreg_ip(
173195

174196
return beta_hat
175197

176-
def fit_qreg(self, X: np.ndarray, Y: np.ndarray, q: float) -> np.ndarray:
177-
"""Fit a quantile regression model and return the coefficients."""
178-
N, k = X.shape
179-
180-
beta_hat, has_converged = frisch_newton_solver(
181-
A=X.T,
182-
b=(1 - q) * X.T @ np.ones(N),
183-
c=-Y,
184-
u=np.ones(N),
185-
q=q,
186-
tol=1e-6,
187-
max_iter=50,
188-
backoff=0.9995,
189-
)
190-
191-
if not has_converged:
192-
warnings.warn(
193-
"The Frisch-Newton Interior Point solver has not converged after 50 iterations."
194-
)
195-
196-
return -beta_hat.flatten()
197-
198198
def _vcov_iid(self) -> np.ndarray:
199199
raise NotImplementedError(
200200
"""vcov = 'iid' for quantile regression is not yet implemented. "
@@ -205,11 +205,11 @@ def _vcov_iid(self) -> np.ndarray:
205205
def _vcov_nid(self) -> np.ndarray:
206206
"Compute nonparametric IID (NID) vcov matrix using the Hall-Sheather bandwidth."
207207
h = get_hall_sheather_bandwidth(q=self._quantile, N=self._N)
208-
beta_hat_plus = self.fit_qreg(X=self._X, Y=self._Y, q=self._quantile + h)
209-
# beta_hat_plus = self.fit_qreg_ip(X = self._X, Y = self._Y, q = self._quantile + h, rng = self._rng)
208+
beta_hat_plus = self.fit_qreg_fn(X=self._X, Y=self._Y, q=self._quantile + h)
209+
# beta_hat_plus = self.fit_qreg_pfn(X = self._X, Y = self._Y, q = self._quantile + h, rng = self._rng)
210210
yhat_plus = self._X @ beta_hat_plus
211-
beta_hat_minus = self.fit_qreg(X=self._X, Y=self._Y, q=self._quantile - h)
212-
# beta_hat_minus = self.fit_qreg_ip(X = self._X, Y = self._Y, q = self._quantile - h, rng = self._rng)
211+
beta_hat_minus = self.fit_qreg_fn(X=self._X, Y=self._Y, q=self._quantile - h)
212+
# beta_hat_minus = self.fit_qreg_pfn(X = self._X, Y = self._Y, q = self._quantile - h, rng = self._rng)
213213
yhat_minus = self._X @ beta_hat_minus
214214

215215
s = (yhat_plus - yhat_minus) / (2 * h)
@@ -269,7 +269,7 @@ def frisch_newton_solver(
269269
b = b.flatten()
270270
u = u.flatten()
271271

272-
x = (1 - 0.5) * np.ones(n)
272+
x = (1 - q) * np.ones(n)
273273
s = u - x
274274
d = c.copy()
275275
d_plus = np.maximum(d, 0)
@@ -285,7 +285,6 @@ def frisch_newton_solver(
285285

286286
# 6) Quick sanity checks (optional)
287287
if True:
288-
# import pdb; pdb.set_trace()
289288
assert np.all(z > 0)
290289
assert np.all(x > 0)
291290
assert np.all(s > 0)
@@ -331,8 +330,8 @@ def step_length(a: tuple, b: tuple, backoff: float = 0.9995):
331330
dw_aff = -w - (w / s) * ds_aff
332331

333332
# Step lengths (eq. (9))
334-
alpha_p_aff = step_length(a=(x, dx_aff), b=(s, ds_aff))
335-
alpha_d_aff = step_length(a=(z, dz_aff), b=(w, dw_aff))
333+
alpha_p_aff = step_length(a=(x, dx_aff), b=(s, ds_aff), backoff=backoff)
334+
alpha_d_aff = step_length(a=(z, dz_aff), b=(w, dw_aff), backoff=backoff)
336335

337336
# 6) Compute mu_new and centering sigma (eq (10))
338337
x_pred = x + alpha_p_aff * dx_aff
@@ -359,8 +358,8 @@ def step_length(a: tuple, b: tuple, backoff: float = 0.9995):
359358
dw_cor = -(w / s) * ds_cor + (mu_targ - ds_aff * dw_aff) / s
360359

361360
# 9) Final step lengths (corrector) — eq (12)
362-
alpha_p_cor = step_length(a=(x, dx_cor), b=(s, ds_cor))
363-
alpha_d_cor = step_length(a=(z, dz_cor), b=(w, dw_cor))
361+
alpha_p_cor = step_length(a=(x, dx_cor), b=(s, ds_cor), backoff=backoff)
362+
alpha_d_cor = step_length(a=(z, dz_cor), b=(w, dw_cor), backoff=backoff)
364363

365364
# 10) Update all variables / corrector step
366365
# Update

pyfixest/report/visualize.py

Lines changed: 67 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Optional, Union
22

3+
import math
34
import matplotlib.pyplot as plt
45
import numpy as np
56
import pandas as pd
@@ -416,6 +417,8 @@ def qplot(
416417
models: ModelInputType,
417418
rename_models: Optional[dict] = None,
418419
figsize: Optional[tuple] = None,
420+
ncol: Optional[int] = None,
421+
nrow: Optional[int] = None,
419422
):
420423
"""
421424
Plot regression quantiles.
@@ -428,7 +431,10 @@ def qplot(
428431
The size of the figure. If None, the default size is used.
429432
rename_models : dict, optional
430433
A dictionary to rename the models. The keys are the original model names and the values the new names.
431-
434+
ncol : int, optional
435+
Number of columns of subplots. Default is None. Note: cannot be set jointly with nrow argument.
436+
nrow : int, optional
437+
Number of rows of subplots. Default is None. Note: cannot be set jointly with ncol argument.
432438
Returns
433439
-------
434440
object
@@ -455,9 +461,12 @@ def qplot(
455461
df_all = pd.concat([df_all, df], axis=0)
456462

457463
df_all.reset_index(inplace=True)
464+
458465
return _qplot(
459466
data=df_all,
460467
figsize=figsize,
468+
nrow=nrow,
469+
ncol=ncol,
461470
)
462471

463472

@@ -698,53 +707,71 @@ def _coefplot_matplotlib(
698707
return f
699708

700709

701-
def _qplot(data: pd.DataFrame, figsize) -> plt.Figure:
710+
def _qplot(data: pd.DataFrame, nrow: Optional[int]=None, ncol: Optional[int]=None, figsize:tuple[int]=(10, 6)):
702711
"""
703-
Plot quantile regression coefficients with 95% confidence intervals.
704-
Each coefficient gets its own panel, quantiles on the x-axis, and
705-
coefficient estimates with error bars on the y-axis.
712+
Plot point estimates ± confidence intervals by quantile,
713+
with one subplot per coefficient.
706714
707715
Parameters
708716
----------
709-
data: pd.DataFrame
710-
Input data sets
711-
Expects `data` to have columns:
712-
- 'Coefficient' (e.g. 'Intercept', 'X1', 'X2')
713-
- 'quantile' (numeric, e.g. 0.1, 0.5, 0.9)
714-
- 'Estimate' (point estimate)
715-
- '2.5%' (lower bound of 95% CI)
716-
- '97.5%' (upper bound of 95% CI)
717-
figsize: tuple
718-
tuple with the figsize of the matplotlib plt.
717+
data : pandas.DataFrame
718+
Must contain columns ['Coefficient', 'quantile', 'Estimate', '2.5%', '97.5%'].
719+
nrow : int, optional
720+
Number of rows of subplots. If both nrow and ncol are None, defaults to 1.
721+
ncol : int, optional
722+
Number of columns of subplots. Exactly one of nrow/ncol must be set, unless both are None.
723+
figsize : tuple, optional
724+
Figure size passed to plt.subplots().
725+
726+
Raises
727+
------
728+
ValueError
729+
If both nrow and ncol are specified.
719730
"""
720-
figsize = set_figsize(figsize, plot_backend="matplotlib")
721-
df = pd.DataFrame(data)
722-
coeffs = df.Coefficient.unique()
723-
724-
fig, axes = plt.subplots(
725-
# nrows=4,
726-
ncols=4,
727-
sharey=True,
728-
figsize=figsize,
729-
)
730-
731-
for ax, coef in zip(axes, coeffs):
732-
sub = df[df["Coefficient"] == coef].sort_values("quantile")
733-
x = sub["quantile"]
734-
y = sub["Estimate"]
735-
lower_err = y - sub["2.5%"]
736-
upper_err = sub["97.5%"] - y
737-
738-
ax.errorbar(x, y, yerr=[lower_err, upper_err], fmt="o-", capsize=5)
731+
# --- default layout: one row if neither is specified ---
732+
if nrow is None and ncol is None:
733+
nrow = 1
734+
735+
# --- error if both specified ---
736+
if (nrow is not None) and (ncol is not None):
737+
raise ValueError("Specify only one of nrow or ncol, not both.")
738+
739+
# --- determine number of panels ---
740+
coeffs = list(data['Coefficient'].unique())
741+
K = len(coeffs)
742+
743+
# compute rows × cols
744+
if nrow is not None:
745+
rows = nrow
746+
cols = math.ceil(K / rows)
747+
else:
748+
cols = ncol
749+
rows = math.ceil(K / cols)
750+
751+
# --- make subplots ---
752+
fig, axes = plt.subplots(rows, cols, figsize=figsize, squeeze=False)
753+
axes = axes.flatten()
754+
755+
# --- plot each coefficient in its own panel ---
756+
for i, coef in enumerate(coeffs):
757+
ax = axes[i]
758+
sub = data[data['Coefficient'] == coef].sort_values('quantile')
759+
q = sub['quantile'].values
760+
est = sub['Estimate'].values
761+
lo = est - sub['2.5%'].values
762+
hi = sub['97.5%'].values - est
763+
764+
ax.errorbar(q, est, yerr=[lo, hi], fmt='o-')
739765
ax.set_title(coef)
740-
ax.set_xlabel("Quantile")
741-
ax.set_xticks(x)
742-
ax.grid(True)
766+
ax.set_xlabel('Quantile')
767+
ax.set_ylabel('Estimate')
768+
769+
# --- hide any unused axes ---
770+
for j in range(K, rows * cols):
771+
axes[j].set_visible(False)
743772

744-
axes[0].set_ylabel("Coefficient estimate")
745-
fig.suptitle("Quantile Regression Coefficients with 95% CIs", y=1.02)
746773
plt.tight_layout()
747-
return fig
774+
return fig, axes
748775

749776

750777
def _get_model_df(

0 commit comments

Comments
 (0)