ThreadHouse / app /pipeline /segmentation.py
paudelapil's picture
Project do-over
65fa0c5
Raw
History Blame Contribute Delete
3.45 kB
import pandas as pd
import numpy as np
def _safe_score(series: pd.Series, ascending: bool = True, default: int = 3) -> pd.Series:
"""
Map a numeric series to a 1-5 score, robust to small / degenerate samples.
pd.qcut(q=5, labels=[1..5]) crashes with
"Bin labels must be one fewer than the number of bin edges"
when there aren't enough unique values to form 5 quintiles. With <5 unique
values, we fall back to as many bins as there are unique values, and if
everything is identical we return `default` for every row.
ascending=True -> higher value gets higher score (used for F, M)
ascending=False -> lower value gets higher score (used for R)
"""
s = series.astype(float)
if s.empty:
return pd.Series([], dtype=int, index=s.index)
n_unique = s.nunique(dropna=True)
if n_unique < 2:
return pd.Series([default] * len(s), index=s.index, dtype=int)
q = min(5, int(n_unique))
labels = list(range(1, q + 1))
if not ascending:
labels = labels[::-1]
try:
ranked = s.rank(method="first")
scored = pd.qcut(ranked, q=q, labels=labels, duplicates="drop")
# If duplicates="drop" still cut bins below the label count, pandas
# would have raised - so a successful return here is safe to .astype(int).
return scored.astype(int)
except (ValueError, TypeError):
# Last-resort: rank into 5 equal-population buckets manually.
ranks = s.rank(method="first", ascending=ascending)
buckets = pd.cut(
ranks,
bins=np.linspace(0, len(s), 6),
labels=[1, 2, 3, 4, 5],
include_lowest=True,
)
return buckets.fillna(default).astype(int)
def score_and_segment(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["is_suspicious"] = ((df["Monetary"] == 0) | (df["TotalItems"] == 0)).astype(int)
df_clean = df[df["is_suspicious"] == 0].copy()
df_susp = df[df["is_suspicious"] == 1].copy()
if not df_clean.empty:
df_clean["R_score"] = _safe_score(df_clean["Recency"], ascending=False)
df_clean["F_score"] = _safe_score(df_clean["Frequency"], ascending=True)
df_clean["M_score"] = _safe_score(df_clean["Monetary"], ascending=True)
df_clean["RFM_Total"] = df_clean["R_score"] + df_clean["F_score"] + df_clean["M_score"]
def assign_segment(row):
r, f, m = row["R_score"], row["F_score"], row["M_score"]
if r >= 4 and f >= 4 and m >= 4: return "Champion"
elif r >= 3 and f >= 4: return "Loyal Customer"
elif r >= 4 and f <= 2 and m <= 2: return "New Customer"
elif r >= 3 and f >= 2 and m >= 3: return "Potential Loyalist"
elif r >= 4 and m >= 3: return "Promising"
elif r <= 2 and f >= 3 and m >= 3: return "At Risk"
elif r <= 2 and f >= 4 and m >= 4: return "Can't Lose Them"
elif r <= 2 and f <= 2 and m <= 2: return "Lost Customer"
elif r == 3 and f <= 2: return "About to Sleep"
else: return "Needs Attention"
df_clean["Segment"] = df_clean.apply(assign_segment, axis=1)
for col in ["R_score", "F_score", "M_score", "RFM_Total"]:
df_susp[col] = 0
df_susp["Segment"] = "Anomalous"
return pd.concat([df_clean, df_susp], ignore_index=True)