Non-Crossing Quantile Constraints¶
Quick Reference
Class: panelbox.models.quantile.monotonicity.QuantileMonotonicity
Import: from panelbox.models.quantile import QuantileMonotonicity
Related classes: CrossingReport, MonotonicityComparison
Overview¶
When quantile regression models are estimated independently at multiple quantile levels, the resulting quantile curves can cross — meaning that the predicted 25th percentile exceeds the predicted 75th percentile for some observations. This is a logical contradiction: by definition, \(Q_\tau(y|X)\) must be monotonically non-decreasing in \(\tau\).
The crossing problem occurs because each quantile regression is estimated separately without imposing the ordering constraint \(Q_{\tau_1}(y|X) \leq Q_{\tau_2}(y|X)\) for \(\tau_1 < \tau_2\). Crossing is more common when:
- The sample size is small
- Many quantile levels are estimated
- Covariates have complex effects
- The data exhibits heteroskedasticity
PanelBox provides tools to detect crossing, fix it via several methods, and prevent it by using the Location-Scale model which guarantees non-crossing by construction.
Quick Example¶
from panelbox.models.quantile import PooledQuantile, QuantileMonotonicity
import numpy as np
# Estimate at multiple quantiles
model = PooledQuantile(endog=y, exog=X, entity_id=entity,
quantiles=[0.1, 0.25, 0.5, 0.75, 0.9])
results = model.fit()
# Detect crossing
report = QuantileMonotonicity.detect_crossing(results.results, X)
report.summary()
# Fix via rearrangement
if report.has_crossing:
fixed = QuantileMonotonicity.rearrangement(results.results, X)
When to Use¶
- After estimating multiple quantiles: always check for crossing when fitting quantile regression at more than one level
- Before reporting results: crossing invalidates the interpretation as a conditional distribution
- Risk management: crossed quantiles produce nonsensical risk measures (e.g., VaR)
- Density estimation: quantile-based density estimates require monotonicity
When Crossing is a Problem
- Predicted distributions can have negative density regions
- Prediction intervals can be inverted (lower bound > upper bound)
- Interpolated quantile functions are not well-defined
- Results cannot be interpreted as a valid conditional distribution
Detailed Guide¶
Detection¶
Use QuantileMonotonicity.detect_crossing() to check for violations:
from panelbox.models.quantile import QuantileMonotonicity
# results.results is a dict {tau: result_object}
report = QuantileMonotonicity.detect_crossing(results.results, X)
# Report attributes
print(f"Has crossing: {report.has_crossing}")
print(f"Total inversions: {report.total_inversions}")
print(f"Percent affected: {report.pct_affected:.2f}%")
The CrossingReport object provides:
| Attribute | Description |
|---|---|
has_crossing |
True if any crossing detected |
total_inversions |
Total number of inversions across all quantile pairs |
pct_affected |
Percentage of observations with at least one inversion |
crossings |
List of dicts with details for each quantile pair |
Each entry in crossings contains:
for c in report.crossings:
tau1, tau2 = c["tau_pair"]
print(f"tau={tau1:.2f} vs tau={tau2:.2f}:")
print(f" Inversions: {c['n_inversions']} ({c['pct_inversions']:.1f}%)")
print(f" Max violation: {c['max_violation']:.4f}")
print(f" Mean violation: {c['mean_violation']:.4f}")
Convert to DataFrame for further analysis:
Fix Method 1: Rearrangement (Chernozhukov et al. 2010)¶
The simplest and most widely used post-hoc correction. For each observation, sort the predicted quantiles to restore monotonicity:
fixed_results = QuantileMonotonicity.rearrangement(results.results, X)
# Verify crossing is fixed
report_fixed = QuantileMonotonicity.detect_crossing(fixed_results, X)
print(f"Crossing after rearrangement: {report_fixed.has_crossing}")
How it works:
- Compute predictions \(\hat{Q}_{\tau_j}(y|X_i) = X_i'\hat{\beta}_{\tau_j}\) for each observation \(i\) and quantile \(j\)
- For each observation, sort the predictions: \(\hat{Q}^*_{\tau_1} \leq \hat{Q}^*_{\tau_2} \leq \ldots\)
- Recover new coefficients via least-squares: \(\hat{\beta}^*_{\tau_j} = (X'X)^{-1}X'\hat{Q}^*_{\tau_j}\)
| Pros | Cons |
|---|---|
| Simple and fast | Post-hoc (not embedded in estimation) |
| Always eliminates crossing | May distort coefficient interpretation |
| Widely accepted in the literature | Approximation — new coefficients are not exact QR solutions |
Fix Method 2: Isotonic Regression¶
Applies monotone smoothing to the coefficient paths across quantiles:
import numpy as np
# Get coefficient matrix (n_tau x n_coef)
tau_list = np.array(sorted(results.results.keys()))
coef_matrix = np.array([results.results[tau].params for tau in tau_list])
# Apply isotonic regression to each coefficient
monotone_coefs = QuantileMonotonicity.isotonic_regression(coef_matrix, tau_list)
How it works: for each coefficient \(\beta_j\), fit an isotonic regression to the path \(\{\beta_j(\tau_1), \ldots, \beta_j(\tau_K)\}\), ensuring monotonicity in \(\tau\).
Note
Isotonic regression on individual coefficients does not guarantee non-crossing of the predicted quantile curves — it only ensures each coefficient is monotone in \(\tau\). For guaranteed non-crossing, use rearrangement or constrained optimization.
Fix Method 3: Constrained Optimization¶
The most principled approach: estimate all quantiles jointly subject to non-crossing constraints:
tau_list = np.array([0.1, 0.25, 0.5, 0.75, 0.9])
fixed_results = QuantileMonotonicity.constrained_qr(
X=X,
y=y,
tau_list=tau_list,
method="trust-constr", # optimization method
max_iter=1000,
verbose=True,
)
# Result is {tau: beta_array}
for tau in tau_list:
print(f"tau={tau:.2f}: beta = {fixed_results[tau]}")
The optimization problem:
| Pros | Cons |
|---|---|
| Most principled (optimizes true objective) | Slowest method |
| Guarantees non-crossing by construction | High-dimensional constraint set |
| Estimates all quantiles jointly | May not converge for large problems |
Fix Method 4: Simultaneous QR with Soft Penalty¶
An alternative that adds a penalty for crossing rather than hard constraints:
fixed_results = QuantileMonotonicity.simultaneous_qr(
X=X,
y=y,
tau_list=tau_list,
lambda_nc=1.0, # penalty strength
max_iter=100,
tol=1e-6,
verbose=True,
)
The penalized objective:
Fix Method 5: Projection¶
Project predictions to the monotone space:
import numpy as np
# Compute predictions matrix (n_obs x n_tau)
predictions = np.column_stack([X @ results.results[tau].params
for tau in sorted(results.results.keys())])
# Project to monotone space
projected = QuantileMonotonicity.project_to_monotone(
predictions,
method="averaging", # or "isotonic"
)
Comparison of Methods¶
| Method | Speed | Quality | Non-crossing Guarantee | Implementation |
|---|---|---|---|---|
| Rearrangement | Fast | Good | Yes (predictions) | Post-hoc |
| Isotonic regression | Fast | Moderate | No (coefficients only) | Post-hoc |
| Constrained QR | Slow | Best | Yes (by construction) | Joint estimation |
| Simultaneous QR | Moderate | Good | Soft (penalty-based) | Joint estimation |
| Location-Scale | Fast | Good | Yes (by construction) | Different model |
Use MonotonicityComparison for systematic comparison:
from panelbox.models.quantile.monotonicity import MonotonicityComparison
comp = MonotonicityComparison(X=X, y=y, tau_list=tau_list)
comparison_df = comp.compare_methods(
methods=["unconstrained", "rearrangement", "isotonic", "constrained"],
verbose=True,
)
print(comparison_df)
The comparison DataFrame shows:
| Column | Description |
|---|---|
method |
Correction method name |
has_crossing |
Whether crossing remains |
total_inversions |
Number of inversions |
pct_affected |
Percentage of observations affected |
total_loss |
Total check loss (objective value) |
avg_loss |
Average check loss per observation |
Visualization¶
# Visualize crossing violations
report.plot_violations(X, results.results)
# Compare coefficient paths across methods
comp.plot_comparison(var_idx=0) # plot for first variable
The Location-Scale Alternative¶
Instead of fixing crossing after estimation, avoid it entirely with the Location-Scale model:
from panelbox.models.quantile import LocationScale
# Non-crossing by construction
ls_model = LocationScale(
data=panel_data,
formula="y ~ x1 + x2",
tau=[0.1, 0.25, 0.5, 0.75, 0.9],
distribution="normal",
)
ls_results = ls_model.fit()
# Guaranteed: Q_0.1 <= Q_0.25 <= Q_0.5 <= Q_0.75 <= Q_0.9
Configuration Options¶
detect_crossing(results, X)¶
| Parameter | Type | Description |
|---|---|---|
results |
dict | {tau: result} with .params attribute |
X |
ndarray | Design matrix for predictions |
Returns: CrossingReport
rearrangement(results, X)¶
| Parameter | Type | Description |
|---|---|---|
results |
dict | {tau: result} |
X |
ndarray | Design matrix |
Returns: dict — new results with rearranged coefficients
constrained_qr(X, y, tau_list, method, max_iter, verbose)¶
| Parameter | Type | Default | Description |
|---|---|---|---|
X |
ndarray | required | Design matrix \((n, p)\) |
y |
ndarray | required | Response \((n,)\) |
tau_list |
ndarray | required | Quantile levels |
method |
str | "trust-constr" |
Optimization method |
max_iter |
int | 1000 |
Maximum iterations |
verbose |
bool | False |
Print progress |
Returns: dict — {tau: beta_array} with non-crossing guarantee
simultaneous_qr(X, y, tau_list, lambda_nc, max_iter, tol, verbose)¶
| Parameter | Type | Default | Description |
|---|---|---|---|
lambda_nc |
float | 1.0 |
Non-crossing penalty strength |
max_iter |
int | 100 |
Maximum iterations |
tol |
float | 1e-6 |
Convergence tolerance |
Returns: dict — {tau: beta_array}
Tutorials¶
| Tutorial | Description | Link |
|---|---|---|
| Non-Crossing Quantiles | Detection, correction, and comparison |
See Also¶
- Pooled Quantile Regression — standard QR (may produce crossing)
- Fixed Effects Quantile Regression — FE QR (may produce crossing)
- Location-Scale Model — non-crossing by construction
- Diagnostics — crossing detection as part of diagnostic workflow
References¶
- Chernozhukov, V., Fernandez-Val, I., & Galichon, A. (2010). Quantile and probability curves without crossing. Econometrica, 78(3), 1093-1125.
- Bondell, H. D., Reich, B. J., & Wang, H. (2010). Noncrossing quantile regression curve estimation. Biometrika, 97(4), 825-838.
- Dette, H., & Volgushev, S. (2008). Non-crossing non-parametric estimates of quantile curves. Journal of the Royal Statistical Society B, 70(3), 609-627.
- Machado, J. A., & Santos Silva, J. M. C. (2019). Quantiles via moments. Journal of Econometrics, 213(1), 145-173.