Skip to content

Commit f830c2d

Browse files
authored
Merge pull request #34 from BasLinders/winsorize-revenue-outliers
Add opt-in winsorization for revenue/price outliers
2 parents b021131 + 7545b7c commit f830c2d

4 files changed

Lines changed: 151 additions & 4 deletions

File tree

main.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
load_and_validate_csv,
1515
refine_activity_labels,
1616
optimize_dataframe_memory,
17+
winsorize_series,
1718
create_analysis_config,
1819
run_full_analysis,
1920
format_business_report,
@@ -551,12 +552,69 @@ def _run():
551552
st.warning(msg)
552553
st.stop()
553554

555+
# ---------------------------------------------------------------------------
556+
# Winsorize revenue/price outliers - opt-in, applied right after the data is
557+
# cached (both raw_df and df_ready) and before anything downstream reads
558+
# 'price' (business insights' AOV/revenue trend/category breakdown, sampling
559+
# strata, etc.), so a handful of extreme values don't dilute those reports.
560+
# Caps values rather than dropping rows - see prox.winsorize_series.
561+
# ---------------------------------------------------------------------------
562+
st.divider()
563+
st.header("2. Handle Outliers")
564+
if "price" not in raw_df.columns:
565+
st.caption("No revenue/price column detected - nothing to winsorize.")
566+
else:
567+
winsorize_enabled = st.checkbox(
568+
"Winsorize Revenue/Price Outliers", value=False,
569+
help=(
570+
"Caps extreme values in the revenue/price column instead of "
571+
"removing those rows, so a handful of outlier orders don't "
572+
"dilute Average Order Value, revenue trend, or category "
573+
"revenue breakdown in Business Insights."
574+
)
575+
)
576+
if winsorize_enabled:
577+
w_col1, w_col2 = st.columns(2)
578+
with w_col1:
579+
winsorize_method_label = st.radio(
580+
"Method", ["Standard Deviation", "Percentile"], horizontal=True,
581+
help=(
582+
"Standard Deviation: caps at mean +/- N standard deviations. "
583+
"Percentile: caps at the Nth/100-Nth percentile band."
584+
)
585+
)
586+
with w_col2:
587+
if winsorize_method_label == "Standard Deviation":
588+
winsorize_param = st.slider(
589+
"Std deviations", 1.0, 5.0, 3.0, 0.5,
590+
help="Values beyond mean +/- this many standard deviations are capped.",
591+
)
592+
else:
593+
winsorize_param = st.slider(
594+
"Percentile cutoff", 0.5, 10.0, 1.0, 0.5,
595+
help="Caps at this percentile and its mirror (e.g. 1 = 1st/99th percentile).",
596+
)
597+
598+
winsorize_method = "std" if winsorize_method_label == "Standard Deviation" else "percentile"
599+
clipped, lower, upper = winsorize_series(raw_df["price"], method=winsorize_method, param=winsorize_param)
600+
n_capped = int(((raw_df["price"] < lower) | (raw_df["price"] > upper)).sum())
601+
602+
raw_df = raw_df.copy()
603+
df_ready = df_ready.copy()
604+
raw_df["price"] = clipped
605+
df_ready["price"] = df_ready["price"].clip(lower, upper)
606+
607+
if n_capped > 0:
608+
st.info(f"Capped {n_capped:,} value(s) to the range [{lower:,.2f}, {upper:,.2f}].")
609+
else:
610+
st.caption("No values fell outside the winsorization bounds - nothing was capped.")
611+
554612
# ---------------------------------------------------------------------------
555613
# Data quality check - surfaced before filtering/analysis, so messy data is
556614
# caught here instead of showing up as a confusing downstream result
557615
# ---------------------------------------------------------------------------
558616
st.divider()
559-
st.header("2. Data Quality Check")
617+
st.header("3. Data Quality Check")
560618
data_quality = check_data_quality(raw_df)
561619
if data_quality["issues"]:
562620
with st.expander(f"{len(data_quality['issues'])} data quality issue(s) found", expanded=True):
@@ -569,7 +627,7 @@ def _run():
569627
# Filter events before analysis
570628
# ---------------------------------------------------------------------------
571629
st.divider()
572-
st.header("3. Filter Events")
630+
st.header("4. Filter Events")
573631
st.caption(
574632
"Remove noisy or irrelevant events before analysis, or narrow it down to "
575633
"just the events you care about. Optional - leave the list empty to "
@@ -646,7 +704,7 @@ def _run():
646704
# Sampling - opt-in, with a warning above a "large" case-count threshold
647705
# ---------------------------------------------------------------------------
648706
st.divider()
649-
st.header("4. Sampling")
707+
st.header("5. Sampling")
650708
enable_sampling = st.checkbox(
651709
"Enable Sampling", value=False,
652710
help=(
@@ -1482,5 +1540,5 @@ def _has_priority_value(series: pd.Series) -> bool:
14821540
# Export: Build a Custom PDF Report
14831541
# ---------------------------------------------------------------------------
14841542
st.divider()
1485-
st.header("5. Build a Custom PDF Report")
1543+
st.header("6. Build a Custom PDF Report")
14861544
render_pdf_builder(results, segment_result=st.session_state.get("segment_result"))

prox/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
sample_log_stratified,
1919
check_trace_length,
2020
check_data_quality,
21+
winsorize_series,
2122
)
2223
from .config import CONFIG, create_analysis_config, get_column_mappings
2324
from .discovery import perform_process_discovery, DISCOVERY_ALGORITHMS
@@ -56,6 +57,7 @@
5657
"sample_log_stratified",
5758
"check_trace_length",
5859
"check_data_quality",
60+
"winsorize_series",
5961
"CONFIG",
6062
"create_analysis_config",
6163
"get_column_mappings",

prox/data_manager.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,37 @@ def sample_log_stratified(
617617
sampled_ids = pd.Series(all_ids).sample(n, replace=False).tolist()
618618
messages.append(f"Random sample: {len(sampled_ids)} cases.")
619619
return event_log_df[event_log_df['case:concept:name'].isin(sampled_ids)].copy(), messages
620+
621+
622+
def winsorize_series(
623+
series: pd.Series, method: str = "std", param: float = 3.0
624+
) -> Tuple[pd.Series, float, float]:
625+
"""
626+
Caps outliers in a numeric Series without dropping rows, so a handful of
627+
extreme values (e.g. one enormous order) don't dilute downstream stats
628+
like Average Order Value or a revenue trend. Same technique as
629+
first-order-engine's ContinuousMetricEngine.winsorize_series, adapted to
630+
return a Series (PRoX's DataFrame-in/DataFrame-out convention) instead of
631+
a JSON-serializable list.
632+
633+
method: "std" caps at mean +/- param standard deviations.
634+
"percentile" caps at the [param, 100 - param] percentile band
635+
(e.g. param=1 -> 1st/99th percentile).
636+
637+
Returns (clipped_series, lower_bound, upper_bound). An empty or all-NaN
638+
series is returned unchanged with bounds of (0.0, 0.0) - nothing to
639+
winsorize against.
640+
"""
641+
series_clean = series.dropna()
642+
if series_clean.empty:
643+
return series.copy(), 0.0, 0.0
644+
645+
if method == "std":
646+
mean_val = series_clean.mean()
647+
std_val = series_clean.std()
648+
lower = mean_val - (param * std_val)
649+
upper = mean_val + (param * std_val)
650+
else: # percentile
651+
lower, upper = np.percentile(series_clean, [param, 100.0 - param])
652+
653+
return series.clip(lower, upper), float(lower), float(upper)

tests/test_data_manager.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
optimize_dataframe_memory,
1111
refine_activity_labels,
1212
check_data_quality,
13+
winsorize_series,
1314
)
1415

1516
from conftest import make_event_log
@@ -334,3 +335,55 @@ def test_check_data_quality_empty_df_returns_no_issues():
334335
result = check_data_quality(pd.DataFrame())
335336
assert result['issues'] == []
336337
assert result['duplicate_events'] == 0
338+
339+
340+
# --- winsorize_series ---
341+
342+
def test_winsorize_series_percentile_caps_a_single_extreme_outlier():
343+
series = pd.Series([10.0, 12.0, 11.0, 9.0, 13.0, 10.0, 11.0, 12.0, 9.0, 999999.0])
344+
clipped, lower, upper = winsorize_series(series, method='percentile', param=10.0)
345+
346+
assert clipped.max() == pytest.approx(upper)
347+
assert clipped.max() < 999999.0
348+
# Every non-outlier value is well inside the band, so only the injected
349+
# outlier should actually get capped.
350+
assert (series[:-1] == clipped[:-1]).all()
351+
352+
353+
def test_winsorize_series_std_caps_at_mean_plus_n_std():
354+
series = pd.Series([10.0, 20.0, 30.0, 40.0, 50.0])
355+
clipped, lower, upper = winsorize_series(series, method='std', param=1.0)
356+
357+
mean, std = series.mean(), series.std()
358+
assert lower == pytest.approx(mean - std)
359+
assert upper == pytest.approx(mean + std)
360+
assert clipped.min() >= lower
361+
assert clipped.max() <= upper
362+
363+
364+
def test_winsorize_series_preserves_nan_positions():
365+
series = pd.Series([10.0, None, 30.0, None, 9999.0])
366+
clipped, lower, upper = winsorize_series(series, method='percentile', param=10.0)
367+
368+
assert clipped.isna().tolist() == [False, True, False, True, False]
369+
370+
371+
def test_winsorize_series_no_outliers_leaves_values_unchanged():
372+
series = pd.Series([10.0, 11.0, 12.0, 13.0, 14.0])
373+
clipped, lower, upper = winsorize_series(series, method='std', param=3.0)
374+
375+
assert (clipped == series).all()
376+
377+
378+
def test_winsorize_series_empty_series_returns_zero_bounds():
379+
clipped, lower, upper = winsorize_series(pd.Series([], dtype=float))
380+
assert clipped.empty
381+
assert lower == 0.0
382+
assert upper == 0.0
383+
384+
385+
def test_winsorize_series_all_nan_returns_zero_bounds():
386+
clipped, lower, upper = winsorize_series(pd.Series([None, None], dtype=float))
387+
assert clipped.isna().all()
388+
assert lower == 0.0
389+
assert upper == 0.0

0 commit comments

Comments
 (0)