Skip to content

Commit 822acdb

Browse files
author
miranov25
committed
Phase 13.25.DF FIX2: unblock discrete quantile mode (P0 design fix)
The general case (one line per quantile) was blocked behind NotImplementedError while only special cases (error_bars, band) were implemented. This is backwards — discrete lines are the trivial default; error_bars and band are optimizations. Fix: - _detect_quantile_mode() returns 'discrete' for any non-symmetric list (was NotImplementedError). Single values like [0.5] also work. - New _compute_per_bin_all_quantiles() helper for arbitrary lists - New discrete rendering branch: one dashed line per quantile value - quantile_mode='discrete' accepted as explicit override - Auto-detection preserved: symmetric pair → error_bars, symmetric triple with 0.5 → band, everything else → discrete Usage: # Any list just works now drawer.profile('y:x', quantiles=[0.1, 0.5, 0.9, 0.99]) # Force discrete on a symmetric pair drawer.profile('y:x', quantiles=[0.16, 0.84], quantile_mode='discrete') Test results: 57Test results: 57Test results: 57Test results: 57Test results: 57Test resuccess assertions)
1 parent 16c3ca4 commit 822acdb

2 files changed

Lines changed: 100 additions & 60 deletions

File tree

UTILS/dfextensions/dfdraw/plots/profile.py

Lines changed: 83 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -321,23 +321,20 @@ def draw_profile(
321321

322322
_resolved_quantile_mode = None
323323
_quantile_pair = None
324+
_quantile_list = None
324325
if quantiles is not None:
325326
# Validate and auto-detect mode
326327
if quantile_mode == 'auto':
327328
_resolved_quantile_mode = _detect_quantile_mode(quantiles)
328-
elif quantile_mode in ('error_bars', 'band'):
329+
elif quantile_mode in ('error_bars', 'band', 'discrete'):
329330
_resolved_quantile_mode = quantile_mode
330331
# Still validate the list
331332
for q in quantiles:
332333
if q <= 0 or q >= 1:
333334
raise ValueError(f"quantiles must be in (0, 1), got {q}")
334-
elif quantile_mode in ('discrete', 'nested_band'):
335-
raise NotImplementedError(
336-
f"Phase B: {quantile_mode} mode is not yet implemented."
337-
)
338335
else:
339336
raise ValueError(
340-
f"quantile_mode must be 'auto', 'error_bars', or 'band', "
337+
f"quantile_mode must be 'auto', 'error_bars', 'band', or 'discrete', "
341338
f"got {quantile_mode!r}"
342339
)
343340

@@ -350,12 +347,16 @@ def draw_profile(
350347
"quantile_mode='band' which supports central='none'."
351348
)
352349

353-
# Extract the quantile pair (lower, upper) for computation
350+
# Extract quantile values for computation
354351
qs = sorted(quantiles)
352+
_quantile_pair = None
353+
_quantile_list = None
355354
if _resolved_quantile_mode == 'error_bars':
356355
_quantile_pair = (qs[0], qs[1])
357356
elif _resolved_quantile_mode == 'band':
358357
_quantile_pair = (qs[0], qs[2]) # skip 0.5 in the middle
358+
elif _resolved_quantile_mode == 'discrete':
359+
_quantile_list = qs # all quantiles, one line each
359360

360361
# AD-52 FIX1: rebind error only when user did NOT explicitly set it.
361362
# None (signature default) → rebind to "quantile" for error_bars mode.
@@ -462,13 +463,21 @@ def draw_profile(
462463

463464
# Phase 13.25.DF: Compute per-bin quantiles if requested
464465
_q_lower = _q_upper = None
466+
_q_all = None # dict {q_value: per_bin_array} for discrete mode
465467
if quantiles is not None and _quantile_pair is not None:
466468
_, _q_lower, _q_upper, _ = _compute_per_bin_quantiles(
467469
x_data, y_data, bins, x_range, _quantile_pair,
468470
)
469471
# Add to stats dict
470472
stats_dict['q_lower_per_bin'] = _q_lower
471473
stats_dict['q_upper_per_bin'] = _q_upper
474+
elif quantiles is not None and _quantile_list is not None:
475+
# Discrete mode: compute per-bin value for EACH quantile
476+
_q_all = _compute_per_bin_all_quantiles(
477+
x_data, y_data, bins, x_range, _quantile_list,
478+
)
479+
# Add to stats dict
480+
stats_dict['quantiles_per_bin'] = _q_all
472481

473482
# Phase 13.25.DF: Compute per-bin median if needed
474483
_bin_medians = None
@@ -531,6 +540,27 @@ def draw_profile(
531540
capsize=capsize, linestyle=linestyle, linewidth=linewidth,
532541
label=label, **kwargs
533542
)
543+
elif _resolved_quantile_mode == 'discrete' and _q_all is not None:
544+
# Discrete mode: one line per quantile value — the general case.
545+
# Central line first (unless central='none')
546+
if central != 'none':
547+
ax.errorbar(
548+
bin_centers[plot_mask], _central_values[plot_mask],
549+
yerr=bin_errors[plot_mask],
550+
fmt=marker, color=color, markersize=markersize,
551+
capsize=capsize, linestyle=linestyle, linewidth=linewidth,
552+
label=label, **kwargs
553+
)
554+
# One dashed line per quantile
555+
_ls_cycle = ['--', '-.', ':', (0, (3, 1, 1, 1))]
556+
for j, (q_val, q_per_bin) in enumerate(_q_all.items()):
557+
q_ls = _ls_cycle[j % len(_ls_cycle)]
558+
q_label = f'q={q_val:.0%}' if q_val != 0.5 else 'median'
559+
ax.plot(
560+
bin_centers[plot_mask], q_per_bin[plot_mask],
561+
color=color, linestyle=q_ls, linewidth=linewidth * 0.8,
562+
label=q_label,
563+
)
534564
else:
535565
# No quantiles — standard profile rendering (existing behavior)
536566
ax.errorbar(
@@ -869,25 +899,13 @@ def _detect_quantile_mode(quantiles: list) -> str:
869899
"""
870900
Auto-detect quantile rendering mode from the shape of the quantiles list.
871901
872-
Phase A supports error_bars and band only; other shapes raise
873-
NotImplementedError (Phase B) or ValueError.
874-
875-
Parameters
876-
----------
877-
quantiles : list of float
878-
Quantile fractions in (0, 1).
902+
The GENERAL case is discrete (one line per quantile). Error_bars and band
903+
are OPTIMIZATIONS for specific symmetric shapes.
879904
880905
Returns
881906
-------
882907
str
883-
'error_bars' or 'band' (Phase A).
884-
885-
Raises
886-
------
887-
ValueError
888-
If quantiles is empty, single-valued, or contains out-of-range values.
889-
NotImplementedError
890-
If quantiles shape requires Phase B modes (discrete-line, nested-band).
908+
'error_bars', 'band', or 'discrete'.
891909
"""
892910
if not quantiles:
893911
raise ValueError("quantiles must be non-empty list of fractions in (0, 1)")
@@ -903,49 +921,26 @@ def _detect_quantile_mode(quantiles: list) -> str:
903921
qs = sorted(quantiles)
904922
n = len(qs)
905923

924+
# Single value → discrete (just one quantile line)
906925
if n == 1:
907-
raise ValueError(
908-
f"quantiles requires at least a symmetric pair (e.g., [0.16, 0.84]), "
909-
f"got single value [{qs[0]}]"
910-
)
926+
return 'discrete'
911927

912-
# Check if symmetric pair (no 0.5)
928+
# Check if symmetric pair (no 0.5) → error_bars optimization
913929
if n == 2:
914930
is_symmetric = abs(qs[0] + qs[1] - 1.0) < 1e-9
915931
has_05 = any(abs(q - 0.5) < 1e-9 for q in qs)
916932
if is_symmetric and not has_05:
917933
return 'error_bars'
918934

919-
# Check if symmetric triple with 0.5
935+
# Check if symmetric triple with 0.5 → band optimization
920936
if n == 3:
921937
has_05 = abs(qs[1] - 0.5) < 1e-9
922938
is_symmetric = abs(qs[0] + qs[2] - 1.0) < 1e-9
923939
if has_05 and is_symmetric:
924940
return 'band'
925941

926-
# Multi-pair symmetric (Phase B: nested-band)
927-
# Check if all non-0.5 entries form symmetric pairs
928-
non_05 = [q for q in qs if abs(q - 0.5) > 1e-9]
929-
if len(non_05) >= 4:
930-
pairs_symmetric = all(
931-
abs(non_05[i] + non_05[-(i+1)] - 1.0) < 1e-9
932-
for i in range(len(non_05) // 2)
933-
)
934-
if pairs_symmetric:
935-
raise NotImplementedError(
936-
"Phase B: nested-band mode is not yet implemented. "
937-
"Use a single symmetric pair (Phase A error_bars, e.g., [0.16, 0.84]) "
938-
"or a symmetric triple including 0.5 (Phase A band, e.g., [0.16, 0.5, 0.84]) "
939-
"until Phase B ships. See brainstorm §8.1."
940-
)
941-
942-
# Asymmetric / arbitrary → Phase B discrete-line
943-
raise NotImplementedError(
944-
"Phase B: discrete-line mode is not yet implemented. "
945-
"Use a single symmetric pair (Phase A error_bars, e.g., [0.16, 0.84]) "
946-
"or a symmetric triple including 0.5 (Phase A band, e.g., [0.16, 0.5, 0.84]) "
947-
"until Phase B ships. See brainstorm §8.1."
948-
)
942+
# Everything else → discrete (one line per quantile)
943+
return 'discrete'
949944

950945

951946
def _compute_per_bin_quantiles(
@@ -1003,6 +998,43 @@ def _compute_per_bin_quantiles(
1003998
return bin_centers, q_lower, q_upper, bin_counts
1004999

10051000

1001+
def _compute_per_bin_all_quantiles(
1002+
x_data: np.ndarray,
1003+
y_data: np.ndarray,
1004+
bins: int,
1005+
x_range,
1006+
quantile_list: list,
1007+
w_data=None,
1008+
) -> dict:
1009+
"""
1010+
Compute per-bin values for ALL quantiles in the list.
1011+
1012+
The general case: any list of quantile fractions.
1013+
Returns a dict {q_value: per_bin_array} for discrete-mode rendering.
1014+
"""
1015+
if w_data is not None:
1016+
raise NotImplementedError("weighted quantiles deferred to Phase B")
1017+
1018+
if x_range is None:
1019+
x_range = (np.nanmin(x_data), np.nanmax(x_data))
1020+
1021+
bin_edges = np.linspace(x_range[0], x_range[1], bins + 1)
1022+
bin_indices = np.clip(np.digitize(x_data, bin_edges) - 1, 0, bins - 1)
1023+
1024+
result = {}
1025+
for q in quantile_list:
1026+
q_per_bin = np.full(bins, np.nan)
1027+
for i in range(bins):
1028+
mask = bin_indices == i
1029+
y_bin = y_data[mask]
1030+
y_bin = y_bin[~np.isnan(y_bin)]
1031+
if len(y_bin) > 0:
1032+
q_per_bin[i] = float(np.nanpercentile(y_bin, q * 100))
1033+
result[q] = q_per_bin
1034+
1035+
return result
1036+
1037+
10061038
def _compute_per_bin_median(
10071039
x_data: np.ndarray,
10081040
y_data: np.ndarray,

UTILS/dfextensions/dfdraw/tests/test_quantiles_profile.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,25 @@ def test_symmetric_triple_with_05_returns_band(self,df_gaussian):
169169
def test_symmetric_pair_p25_p75_returns_error_bars(self,df_gaussian):
170170
_,_,s=DFDraw(df_gaussian).profile("y:x",bins=20,quantiles=[.25,.75])
171171
assert 'q_lower_per_bin' in s; plt.close('all')
172-
def test_asymmetric_raises_notimplementederror_phaseb(self,df_gaussian):
173-
with pytest.raises(NotImplementedError,match="Phase B"):
174-
DFDraw(df_gaussian).profile("y:x",bins=20,quantiles=[.1,.5,.9,.99])
172+
def test_asymmetric_returns_discrete(self,df_gaussian):
173+
"""Asymmetric lists → discrete mode (one line per quantile)."""
174+
_,ax,s=DFDraw(df_gaussian).profile("y:x",bins=20,quantiles=[.1,.5,.9,.99])
175+
assert 'quantiles_per_bin' in s
176+
assert len(s['quantiles_per_bin'])==4 # one array per quantile
177+
lines=ax.get_lines()
178+
assert len(lines)>=4,f"Expected ≥4 lines (central + 4 quantiles), got {len(lines)}"
175179
plt.close('all')
176-
def test_multi_pair_raises_notimplementederror_phaseb(self,df_gaussian):
177-
with pytest.raises(NotImplementedError,match="Phase B"):
178-
DFDraw(df_gaussian).profile("y:x",bins=20,quantiles=[.05,.25,.5,.75,.95])
180+
def test_multi_pair_returns_discrete(self,df_gaussian):
181+
"""Multi-pair symmetric → discrete mode (one line per quantile)."""
182+
_,ax,s=DFDraw(df_gaussian).profile("y:x",bins=20,quantiles=[.05,.25,.5,.75,.95])
183+
assert 'quantiles_per_bin' in s
184+
assert len(s['quantiles_per_bin'])==5
179185
plt.close('all')
180-
def test_single_value_raises_valueerror(self,df_gaussian):
181-
with pytest.raises(ValueError,match="at least a symmetric pair"):
182-
DFDraw(df_gaussian).profile("y:x",bins=20,quantiles=[.5])
186+
def test_single_value_returns_discrete(self,df_gaussian):
187+
"""Single quantile [0.5] → discrete mode (one quantile line)."""
188+
_,ax,s=DFDraw(df_gaussian).profile("y:x",bins=20,quantiles=[.5])
189+
assert 'quantiles_per_bin' in s
190+
assert len(s['quantiles_per_bin'])==1
183191
plt.close('all')
184192
def test_out_of_range_raises_valueerror(self,df_gaussian):
185193
with pytest.raises(ValueError,match="must be in"):

0 commit comments

Comments
 (0)