Initial commit: frontend + backend integration
This commit is contained in:
441
backend/beyond_metrics/dimensions/EconomyCost.py
Normal file
441
backend/beyond_metrics/dimensions/EconomyCost.py
Normal file
@@ -0,0 +1,441 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.axes import Axes
|
||||
|
||||
|
||||
REQUIRED_COLUMNS_ECON: List[str] = [
|
||||
"interaction_id",
|
||||
"datetime_start",
|
||||
"queue_skill",
|
||||
"channel",
|
||||
"duration_talk",
|
||||
"hold_time",
|
||||
"wrap_up_time",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class EconomyConfig:
|
||||
"""
|
||||
Parámetros manuales para la dimensión de Economía y Costes.
|
||||
|
||||
- labor_cost_per_hour: coste total/hora de un agente (fully loaded).
|
||||
- overhead_rate: % overhead variable (ej. 0.1 = 10% sobre labor).
|
||||
- tech_costs_annual: coste anual de tecnología (licencias, infra, ...).
|
||||
- automation_cpi: coste por interacción automatizada (ej. 0.15€).
|
||||
- automation_volume_share: % del volumen automatizable (0-1).
|
||||
- automation_success_rate: % éxito de la automatización (0-1).
|
||||
|
||||
- customer_segments: mapping opcional skill -> segmento ("high"/"medium"/"low")
|
||||
para futuros insights de ROI por segmento.
|
||||
"""
|
||||
|
||||
labor_cost_per_hour: float
|
||||
overhead_rate: float = 0.0
|
||||
tech_costs_annual: float = 0.0
|
||||
automation_cpi: Optional[float] = None
|
||||
automation_volume_share: float = 0.0
|
||||
automation_success_rate: float = 0.0
|
||||
customer_segments: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EconomyCostMetrics:
|
||||
"""
|
||||
DIMENSIÓN 4: ECONOMÍA y COSTES
|
||||
|
||||
Propósito:
|
||||
- Cuantificar el COSTE actual (CPI, coste anual).
|
||||
- Estimar el impacto de overhead y tecnología.
|
||||
- Calcular un primer estimado de "coste de ineficiencia" y ahorro potencial.
|
||||
|
||||
Requiere:
|
||||
- Columnas del dataset transaccional (ver REQUIRED_COLUMNS_ECON).
|
||||
|
||||
Inputs opcionales vía EconomyConfig:
|
||||
- labor_cost_per_hour (obligatorio para cualquier cálculo de €).
|
||||
- overhead_rate, tech_costs_annual, automation_*.
|
||||
- customer_segments (para insights de ROI por segmento).
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
config: Optional[EconomyConfig] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_columns()
|
||||
self._prepare_data()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers internos
|
||||
# ------------------------------------------------------------------ #
|
||||
def _validate_columns(self) -> None:
|
||||
missing = [c for c in REQUIRED_COLUMNS_ECON if c not in self.df.columns]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Faltan columnas obligatorias para EconomyCostMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
df = self.df.copy()
|
||||
|
||||
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
|
||||
|
||||
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
||||
df["channel"] = df["channel"].astype(str).str.strip()
|
||||
|
||||
# Handle time = talk + hold + wrap
|
||||
df["handle_time"] = (
|
||||
df["duration_talk"].fillna(0)
|
||||
+ df["hold_time"].fillna(0)
|
||||
+ df["wrap_up_time"].fillna(0)
|
||||
) # segundos
|
||||
|
||||
self.df = df
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.df.empty
|
||||
|
||||
def _has_cost_config(self) -> bool:
|
||||
return self.config is not None and self.config.labor_cost_per_hour is not None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 1: CPI por canal/skill
|
||||
# ------------------------------------------------------------------ #
|
||||
def cpi_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
CPI (Coste Por Interacción) por skill/canal.
|
||||
|
||||
CPI = Labor_cost_per_interaction + Overhead_variable
|
||||
|
||||
- Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours)
|
||||
- Overhead_variable = overhead_rate * Labor_cost_per_interaction
|
||||
|
||||
Si no hay config de costes -> devuelve DataFrame vacío.
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return pd.DataFrame()
|
||||
|
||||
cfg = self.config
|
||||
assert cfg is not None # para el type checker
|
||||
|
||||
df = self.df.copy()
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# AHT por skill/canal (en segundos)
|
||||
grouped = df.groupby(["queue_skill", "channel"])["handle_time"].mean()
|
||||
|
||||
if grouped.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
aht_sec = grouped
|
||||
aht_hours = aht_sec / 3600.0
|
||||
|
||||
labor_cost = cfg.labor_cost_per_hour * aht_hours
|
||||
overhead = labor_cost * cfg.overhead_rate
|
||||
cpi = labor_cost + overhead
|
||||
|
||||
out = pd.DataFrame(
|
||||
{
|
||||
"aht_seconds": aht_sec.round(2),
|
||||
"labor_cost": labor_cost.round(4),
|
||||
"overhead_cost": overhead.round(4),
|
||||
"cpi_total": cpi.round(4),
|
||||
}
|
||||
)
|
||||
|
||||
return out.sort_index()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 2: coste anual por skill/canal
|
||||
# ------------------------------------------------------------------ #
|
||||
def annual_cost_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
Coste anual por skill/canal.
|
||||
|
||||
cost_annual = CPI * volumen (cantidad de interacciones de la muestra).
|
||||
|
||||
Nota: por simplicidad asumimos que el dataset refleja un periodo anual.
|
||||
Si en el futuro quieres anualizar (ej. dataset = 1 mes) se puede añadir
|
||||
un factor de escalado en EconomyConfig.
|
||||
"""
|
||||
cpi_table = self.cpi_by_skill_channel()
|
||||
if cpi_table.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = self.df.copy()
|
||||
volume = (
|
||||
df.groupby(["queue_skill", "channel"])["interaction_id"]
|
||||
.nunique()
|
||||
.rename("volume")
|
||||
)
|
||||
|
||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
||||
joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2)
|
||||
|
||||
return joined
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 3: desglose de costes (labor / tech / overhead)
|
||||
# ------------------------------------------------------------------ #
|
||||
def cost_breakdown(self) -> Dict[str, float]:
|
||||
"""
|
||||
Desglose % de costes: labor, overhead, tech.
|
||||
|
||||
labor_total = sum(labor_cost_per_interaction)
|
||||
overhead_total = labor_total * overhead_rate
|
||||
tech_total = tech_costs_annual (si se ha proporcionado)
|
||||
|
||||
Devuelve porcentajes sobre el total.
|
||||
Si falta configuración de coste -> devuelve {}.
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return {}
|
||||
|
||||
cfg = self.config
|
||||
assert cfg is not None
|
||||
|
||||
cpi_table = self.cpi_by_skill_channel()
|
||||
if cpi_table.empty:
|
||||
return {}
|
||||
|
||||
df = self.df.copy()
|
||||
volume = (
|
||||
df.groupby(["queue_skill", "channel"])["interaction_id"]
|
||||
.nunique()
|
||||
.rename("volume")
|
||||
)
|
||||
|
||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
||||
|
||||
# Costes anuales de labor y overhead
|
||||
annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
|
||||
annual_overhead = (joined["overhead_cost"] * joined["volume"]).sum()
|
||||
annual_tech = cfg.tech_costs_annual
|
||||
|
||||
total = annual_labor + annual_overhead + annual_tech
|
||||
if total <= 0:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"labor_pct": round(annual_labor / total * 100, 2),
|
||||
"overhead_pct": round(annual_overhead / total * 100, 2),
|
||||
"tech_pct": round(annual_tech / total * 100, 2),
|
||||
"labor_annual": round(annual_labor, 2),
|
||||
"overhead_annual": round(annual_overhead, 2),
|
||||
"tech_annual": round(annual_tech, 2),
|
||||
"total_annual": round(total, 2),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 4: coste de ineficiencia (€ por variabilidad/escalación)
|
||||
# ------------------------------------------------------------------ #
|
||||
def inefficiency_cost_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
Estimación muy simplificada de coste de ineficiencia:
|
||||
|
||||
Para cada skill/canal:
|
||||
|
||||
- AHT_p50, AHT_p90 (segundos).
|
||||
- Delta = max(0, AHT_p90 - AHT_p50).
|
||||
- Se asume que ~40% de las interacciones están por encima de la mediana.
|
||||
- Ineff_seconds = Delta * volume * 0.4
|
||||
- Ineff_cost = LaborCPI_per_second * Ineff_seconds
|
||||
|
||||
⚠️ Es un modelo aproximado para cuantificar "orden de magnitud".
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return pd.DataFrame()
|
||||
|
||||
cfg = self.config
|
||||
assert cfg is not None
|
||||
|
||||
df = self.df.copy()
|
||||
grouped = df.groupby(["queue_skill", "channel"])
|
||||
|
||||
stats = grouped["handle_time"].agg(
|
||||
aht_p50=lambda s: float(np.percentile(s.dropna(), 50)),
|
||||
aht_p90=lambda s: float(np.percentile(s.dropna(), 90)),
|
||||
volume="count",
|
||||
)
|
||||
|
||||
if stats.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# CPI para obtener coste/segundo de labor
|
||||
cpi_table = self.cpi_by_skill_channel()
|
||||
if cpi_table.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
merged = stats.join(cpi_table[["labor_cost"]], how="left")
|
||||
merged = merged.fillna(0.0)
|
||||
|
||||
delta = (merged["aht_p90"] - merged["aht_p50"]).clip(lower=0.0)
|
||||
affected_fraction = 0.4 # aproximación
|
||||
ineff_seconds = delta * merged["volume"] * affected_fraction
|
||||
|
||||
# labor_cost = coste por interacción con AHT medio;
|
||||
# aproximamos coste/segundo como labor_cost / AHT_medio
|
||||
aht_mean = grouped["handle_time"].mean()
|
||||
merged["aht_mean"] = aht_mean
|
||||
|
||||
cost_per_second = merged["labor_cost"] / merged["aht_mean"].replace(0, np.nan)
|
||||
cost_per_second = cost_per_second.fillna(0.0)
|
||||
|
||||
ineff_cost = (ineff_seconds * cost_per_second).round(2)
|
||||
|
||||
merged["ineff_seconds"] = ineff_seconds.round(2)
|
||||
merged["ineff_cost"] = ineff_cost
|
||||
|
||||
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPI 5: ahorro potencial anual por automatización
|
||||
# ------------------------------------------------------------------ #
|
||||
def potential_savings(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Ahorro potencial anual basado en:
|
||||
|
||||
Ahorro = (CPI_humano - CPI_automatizado) * Volumen_automatizable * Tasa_éxito
|
||||
|
||||
Donde:
|
||||
- CPI_humano = media ponderada de cpi_total.
|
||||
- CPI_automatizado = config.automation_cpi
|
||||
- Volumen_automatizable = volume_total * automation_volume_share
|
||||
- Tasa_éxito = automation_success_rate
|
||||
|
||||
Si faltan parámetros en config -> devuelve {}.
|
||||
"""
|
||||
if not self._has_cost_config():
|
||||
return {}
|
||||
|
||||
cfg = self.config
|
||||
assert cfg is not None
|
||||
|
||||
if cfg.automation_cpi is None or cfg.automation_volume_share <= 0 or cfg.automation_success_rate <= 0:
|
||||
return {}
|
||||
|
||||
cpi_table = self.annual_cost_by_skill_channel()
|
||||
if cpi_table.empty:
|
||||
return {}
|
||||
|
||||
total_volume = cpi_table["volume"].sum()
|
||||
if total_volume <= 0:
|
||||
return {}
|
||||
|
||||
# CPI humano medio ponderado
|
||||
weighted_cpi = (
|
||||
(cpi_table["cpi_total"] * cpi_table["volume"]).sum() / total_volume
|
||||
)
|
||||
|
||||
volume_automatizable = total_volume * cfg.automation_volume_share
|
||||
effective_volume = volume_automatizable * cfg.automation_success_rate
|
||||
|
||||
delta_cpi = max(0.0, weighted_cpi - cfg.automation_cpi)
|
||||
annual_savings = delta_cpi * effective_volume
|
||||
|
||||
return {
|
||||
"cpi_humano": round(weighted_cpi, 4),
|
||||
"cpi_automatizado": round(cfg.automation_cpi, 4),
|
||||
"volume_total": float(total_volume),
|
||||
"volume_automatizable": float(volume_automatizable),
|
||||
"effective_volume": float(effective_volume),
|
||||
"annual_savings": round(annual_savings, 2),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PLOTS
|
||||
# ------------------------------------------------------------------ #
|
||||
def plot_cost_waterfall(self) -> Axes:
|
||||
"""
|
||||
Waterfall de costes anuales (labor + tech + overhead).
|
||||
"""
|
||||
breakdown = self.cost_breakdown()
|
||||
if not breakdown:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin configuración de costes", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
labels = ["Labor", "Overhead", "Tech"]
|
||||
values = [
|
||||
breakdown["labor_annual"],
|
||||
breakdown["overhead_annual"],
|
||||
breakdown["tech_annual"],
|
||||
]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4))
|
||||
|
||||
running = 0.0
|
||||
positions = []
|
||||
bottoms = []
|
||||
|
||||
for v in values:
|
||||
positions.append(running)
|
||||
bottoms.append(running)
|
||||
running += v
|
||||
|
||||
# barras estilo waterfall
|
||||
x = np.arange(len(labels))
|
||||
ax.bar(x, values)
|
||||
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(labels)
|
||||
ax.set_ylabel("€ anuales")
|
||||
ax.set_title("Desglose anual de costes")
|
||||
|
||||
for idx, v in enumerate(values):
|
||||
ax.text(idx, v, f"{v:,.0f}", ha="center", va="bottom")
|
||||
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
|
||||
def plot_cpi_by_channel(self) -> Axes:
|
||||
"""
|
||||
Gráfico de barras de CPI medio por canal.
|
||||
"""
|
||||
cpi_table = self.cpi_by_skill_channel()
|
||||
if cpi_table.empty:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin configuración de costes", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
df = self.df.copy()
|
||||
volume = (
|
||||
df.groupby(["queue_skill", "channel"])["interaction_id"]
|
||||
.nunique()
|
||||
.rename("volume")
|
||||
)
|
||||
|
||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
||||
|
||||
# CPI medio ponderado por canal
|
||||
per_channel = (
|
||||
joined.reset_index()
|
||||
.groupby("channel")
|
||||
.apply(lambda g: (g["cpi_total"] * g["volume"]).sum() / max(g["volume"].sum(), 1))
|
||||
.rename("cpi_mean")
|
||||
.round(4)
|
||||
)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(6, 4))
|
||||
per_channel.plot(kind="bar", ax=ax)
|
||||
|
||||
ax.set_xlabel("Canal")
|
||||
ax.set_ylabel("CPI medio (€)")
|
||||
ax.set_title("Coste por interacción (CPI) por canal")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
481
backend/beyond_metrics/dimensions/OperationalPerformance.py
Normal file
481
backend/beyond_metrics/dimensions/OperationalPerformance.py
Normal file
@@ -0,0 +1,481 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.axes import Axes
|
||||
|
||||
|
||||
REQUIRED_COLUMNS_OP: List[str] = [
|
||||
"interaction_id",
|
||||
"datetime_start",
|
||||
"queue_skill",
|
||||
"channel",
|
||||
"duration_talk",
|
||||
"hold_time",
|
||||
"wrap_up_time",
|
||||
"agent_id",
|
||||
"transfer_flag",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationalPerformanceMetrics:
|
||||
"""
|
||||
Dimensión: RENDIMIENTO OPERACIONAL Y DE SERVICIO
|
||||
|
||||
Propósito: medir el balance entre rapidez (eficiencia) y calidad de resolución,
|
||||
más la variabilidad del servicio.
|
||||
|
||||
Requiere como mínimo:
|
||||
- interaction_id
|
||||
- datetime_start
|
||||
- queue_skill
|
||||
- channel
|
||||
- duration_talk (segundos)
|
||||
- hold_time (segundos)
|
||||
- wrap_up_time (segundos)
|
||||
- agent_id
|
||||
- transfer_flag (bool/int)
|
||||
|
||||
Columnas opcionales:
|
||||
- is_resolved (bool/int) -> para FCR
|
||||
- abandoned_flag (bool/int) -> para tasa de abandono
|
||||
- customer_id / caller_id -> para reincidencia y repetición de canal
|
||||
- logged_time (segundos) -> para occupancy_rate
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
|
||||
# Benchmarks / parámetros de normalización (puedes ajustarlos)
|
||||
AHT_GOOD: float = 300.0 # 5 min
|
||||
AHT_BAD: float = 900.0 # 15 min
|
||||
VAR_RATIO_GOOD: float = 1.2 # P90/P50 ~1.2 muy estable
|
||||
VAR_RATIO_BAD: float = 3.0 # P90/P50 >=3 muy inestable
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_columns()
|
||||
self._prepare_data()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers internos
|
||||
# ------------------------------------------------------------------ #
|
||||
def _validate_columns(self) -> None:
|
||||
missing = [c for c in REQUIRED_COLUMNS_OP if c not in self.df.columns]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Faltan columnas obligatorias para OperationalPerformanceMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
df = self.df.copy()
|
||||
|
||||
# Tipos
|
||||
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
|
||||
|
||||
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
# Handle Time
|
||||
df["handle_time"] = (
|
||||
df["duration_talk"].fillna(0)
|
||||
+ df["hold_time"].fillna(0)
|
||||
+ df["wrap_up_time"].fillna(0)
|
||||
)
|
||||
|
||||
# Normalización básica
|
||||
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
||||
df["channel"] = df["channel"].astype(str).str.strip()
|
||||
df["agent_id"] = df["agent_id"].astype(str).str.strip()
|
||||
|
||||
# Flags opcionales convertidos a bool cuando existan
|
||||
for flag_col in ["is_resolved", "abandoned_flag", "transfer_flag"]:
|
||||
if flag_col in df.columns:
|
||||
df[flag_col] = df[flag_col].astype(int).astype(bool)
|
||||
|
||||
# customer_id: usamos customer_id si existe, si no caller_id
|
||||
if "customer_id" in df.columns:
|
||||
df["customer_id"] = df["customer_id"].astype(str)
|
||||
elif "caller_id" in df.columns:
|
||||
df["customer_id"] = df["caller_id"].astype(str)
|
||||
else:
|
||||
df["customer_id"] = None
|
||||
|
||||
# logged_time opcional
|
||||
# Normalizamos logged_time: siempre será una serie float con NaN si no existe
|
||||
df["logged_time"] = pd.to_numeric(df.get("logged_time", np.nan), errors="coerce")
|
||||
|
||||
|
||||
self.df = df
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.df.empty
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# AHT y variabilidad
|
||||
# ------------------------------------------------------------------ #
|
||||
def aht_distribution(self) -> Dict[str, float]:
|
||||
"""
|
||||
Devuelve P10, P50, P90 del AHT y el ratio P90/P50 como medida de variabilidad.
|
||||
"""
|
||||
ht = self.df["handle_time"].dropna().astype(float)
|
||||
if ht.empty:
|
||||
return {}
|
||||
|
||||
p10 = float(np.percentile(ht, 10))
|
||||
p50 = float(np.percentile(ht, 50))
|
||||
p90 = float(np.percentile(ht, 90))
|
||||
ratio = float(p90 / p50) if p50 > 0 else float("nan")
|
||||
|
||||
return {
|
||||
"p10": round(p10, 2),
|
||||
"p50": round(p50, 2),
|
||||
"p90": round(p90, 2),
|
||||
"p90_p50_ratio": round(ratio, 3),
|
||||
}
|
||||
|
||||
def talk_hold_acw_p50_by_skill(self) -> pd.DataFrame:
|
||||
"""
|
||||
P50 de talk_time, hold_time y wrap_up_time por skill.
|
||||
"""
|
||||
df = self.df
|
||||
|
||||
def perc(s: pd.Series, q: float) -> float:
|
||||
s = s.dropna().astype(float)
|
||||
if s.empty:
|
||||
return float("nan")
|
||||
return float(np.percentile(s, q))
|
||||
|
||||
grouped = df.groupby("queue_skill")
|
||||
result = pd.DataFrame(
|
||||
{
|
||||
"talk_p50": grouped["duration_talk"].apply(lambda s: perc(s, 50)),
|
||||
"hold_p50": grouped["hold_time"].apply(lambda s: perc(s, 50)),
|
||||
"acw_p50": grouped["wrap_up_time"].apply(lambda s: perc(s, 50)),
|
||||
}
|
||||
)
|
||||
return result.round(2).sort_index()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# FCR, escalación, abandono, reincidencia, repetición canal
|
||||
# ------------------------------------------------------------------ #
|
||||
def fcr_rate(self) -> float:
|
||||
"""
|
||||
FCR = % de interacciones resueltas en el primer contacto.
|
||||
|
||||
Definido como % de filas con is_resolved == True.
|
||||
Si la columna no existe, devuelve NaN.
|
||||
"""
|
||||
df = self.df
|
||||
if "is_resolved" not in df.columns:
|
||||
return float("nan")
|
||||
|
||||
total = len(df)
|
||||
if total == 0:
|
||||
return float("nan")
|
||||
|
||||
resolved = df["is_resolved"].sum()
|
||||
return float(round(resolved / total * 100, 2))
|
||||
|
||||
def escalation_rate(self) -> float:
|
||||
"""
|
||||
% de interacciones que requieren escalación (transfer_flag == True).
|
||||
"""
|
||||
df = self.df
|
||||
total = len(df)
|
||||
if total == 0:
|
||||
return float("nan")
|
||||
|
||||
escalated = df["transfer_flag"].sum()
|
||||
return float(round(escalated / total * 100, 2))
|
||||
|
||||
def abandonment_rate(self) -> float:
|
||||
"""
|
||||
% de interacciones abandonadas.
|
||||
|
||||
Definido como % de filas con abandoned_flag == True.
|
||||
Si la columna no existe, devuelve NaN.
|
||||
"""
|
||||
df = self.df
|
||||
if "abandoned_flag" not in df.columns:
|
||||
return float("nan")
|
||||
|
||||
total = len(df)
|
||||
if total == 0:
|
||||
return float("nan")
|
||||
|
||||
abandoned = df["abandoned_flag"].sum()
|
||||
return float(round(abandoned / total * 100, 2))
|
||||
|
||||
def recurrence_rate_7d(self) -> float:
|
||||
"""
|
||||
% de clientes que vuelven a contactar en < 7 días.
|
||||
|
||||
Se basa en customer_id (o caller_id si no hay customer_id).
|
||||
Calcula:
|
||||
- Para cada cliente, ordena por datetime_start
|
||||
- Si hay dos contactos consecutivos separados < 7 días, cuenta como "recurrente"
|
||||
- Tasa = nº clientes recurrentes / nº total de clientes
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df["customer_id"].isna().all():
|
||||
return float("nan")
|
||||
|
||||
customers = df["customer_id"].dropna().unique()
|
||||
if len(customers) == 0:
|
||||
return float("nan")
|
||||
|
||||
recurrent_customers = 0
|
||||
|
||||
for cust in customers:
|
||||
sub = df[df["customer_id"] == cust].sort_values("datetime_start")
|
||||
if len(sub) < 2:
|
||||
continue
|
||||
deltas = sub["datetime_start"].diff().dropna()
|
||||
if (deltas < pd.Timedelta(days=7)).any():
|
||||
recurrent_customers += 1
|
||||
|
||||
if len(customers) == 0:
|
||||
return float("nan")
|
||||
|
||||
return float(round(recurrent_customers / len(customers) * 100, 2))
|
||||
|
||||
def repeat_channel_rate(self) -> float:
|
||||
"""
|
||||
% de reincidencias (<7 días) en las que el cliente usa el MISMO canal.
|
||||
|
||||
Si no hay customer_id/caller_id o solo un contacto por cliente, devuelve NaN.
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df["customer_id"].isna().all():
|
||||
return float("nan")
|
||||
|
||||
df = df.sort_values(["customer_id", "datetime_start"])
|
||||
df["next_customer"] = df["customer_id"].shift(-1)
|
||||
df["next_datetime"] = df["datetime_start"].shift(-1)
|
||||
df["next_channel"] = df["channel"].shift(-1)
|
||||
|
||||
same_customer = df["customer_id"] == df["next_customer"]
|
||||
within_7d = (df["next_datetime"] - df["datetime_start"]) < pd.Timedelta(days=7)
|
||||
|
||||
recurrent_mask = same_customer & within_7d
|
||||
if not recurrent_mask.any():
|
||||
return float("nan")
|
||||
|
||||
same_channel = df["channel"] == df["next_channel"]
|
||||
same_channel_recurrent = (recurrent_mask & same_channel).sum()
|
||||
total_recurrent = recurrent_mask.sum()
|
||||
|
||||
return float(round(same_channel_recurrent / total_recurrent * 100, 2))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Occupancy
|
||||
# ------------------------------------------------------------------ #
|
||||
def occupancy_rate(self) -> float:
|
||||
"""
|
||||
Tasa de ocupación:
|
||||
|
||||
occupancy = sum(handle_time) / sum(logged_time) * 100.
|
||||
|
||||
Requiere columna 'logged_time'. Si no existe o es todo 0, devuelve NaN.
|
||||
"""
|
||||
df = self.df
|
||||
if "logged_time" not in df.columns:
|
||||
return float("nan")
|
||||
|
||||
logged = df["logged_time"].fillna(0)
|
||||
handle = df["handle_time"].fillna(0)
|
||||
|
||||
total_logged = logged.sum()
|
||||
if total_logged == 0:
|
||||
return float("nan")
|
||||
|
||||
occ = handle.sum() / total_logged
|
||||
return float(round(occ * 100, 2))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Score de rendimiento 0-10
|
||||
# ------------------------------------------------------------------ #
|
||||
def performance_score(self) -> Dict[str, float]:
|
||||
"""
|
||||
Calcula un score 0-10 combinando:
|
||||
- AHT (bajo es mejor)
|
||||
- FCR (alto es mejor)
|
||||
- Variabilidad (P90/P50, bajo es mejor)
|
||||
- Otros factores (ocupación / escalación)
|
||||
|
||||
Fórmula:
|
||||
score = 0.4 * (10 - AHT_norm) +
|
||||
0.3 * FCR_norm +
|
||||
0.2 * (10 - Var_norm) +
|
||||
0.1 * Otros_score
|
||||
|
||||
Donde *_norm son valores en escala 0-10.
|
||||
"""
|
||||
dist = self.aht_distribution()
|
||||
if not dist:
|
||||
return {"score": float("nan")}
|
||||
|
||||
p50 = dist["p50"]
|
||||
ratio = dist["p90_p50_ratio"]
|
||||
|
||||
# AHT_normalized: 0 (mejor) a 10 (peor)
|
||||
aht_norm = self._scale_to_0_10(p50, self.AHT_GOOD, self.AHT_BAD)
|
||||
# FCR_normalized: 0-10 directamente desde % (0-100)
|
||||
fcr_pct = self.fcr_rate()
|
||||
fcr_norm = fcr_pct / 10.0 if not np.isnan(fcr_pct) else 0.0
|
||||
# Variabilidad_normalized: 0 (ratio bueno) a 10 (ratio malo)
|
||||
var_norm = self._scale_to_0_10(ratio, self.VAR_RATIO_GOOD, self.VAR_RATIO_BAD)
|
||||
|
||||
# Otros factores: combinamos ocupación (ideal ~80%) y escalación (ideal baja)
|
||||
occ = self.occupancy_rate()
|
||||
esc = self.escalation_rate()
|
||||
|
||||
other_score = self._compute_other_factors_score(occ, esc)
|
||||
|
||||
score = (
|
||||
0.4 * (10.0 - aht_norm)
|
||||
+ 0.3 * fcr_norm
|
||||
+ 0.2 * (10.0 - var_norm)
|
||||
+ 0.1 * other_score
|
||||
)
|
||||
|
||||
# Clamp 0-10
|
||||
score = max(0.0, min(10.0, score))
|
||||
|
||||
return {
|
||||
"score": round(score, 2),
|
||||
"aht_norm": round(aht_norm, 2),
|
||||
"fcr_norm": round(fcr_norm, 2),
|
||||
"var_norm": round(var_norm, 2),
|
||||
"other_score": round(other_score, 2),
|
||||
}
|
||||
|
||||
def _scale_to_0_10(self, value: float, good: float, bad: float) -> float:
|
||||
"""
|
||||
Escala linealmente un valor:
|
||||
- good -> 0
|
||||
- bad -> 10
|
||||
Con saturación fuera de rango.
|
||||
"""
|
||||
if np.isnan(value):
|
||||
return 5.0 # neutro
|
||||
|
||||
if good == bad:
|
||||
return 5.0
|
||||
|
||||
if good < bad:
|
||||
# Menor es mejor
|
||||
if value <= good:
|
||||
return 0.0
|
||||
if value >= bad:
|
||||
return 10.0
|
||||
return 10.0 * (value - good) / (bad - good)
|
||||
else:
|
||||
# Mayor es mejor
|
||||
if value >= good:
|
||||
return 0.0
|
||||
if value <= bad:
|
||||
return 10.0
|
||||
return 10.0 * (good - value) / (good - bad)
|
||||
|
||||
def _compute_other_factors_score(self, occ_pct: float, esc_pct: float) -> float:
|
||||
"""
|
||||
Otros factores (0-10) basados en:
|
||||
- ocupación ideal alrededor de 80%
|
||||
- tasa de escalación ideal baja (<10%)
|
||||
"""
|
||||
# Ocupación: 0 penalización si está entre 75-85, se penaliza fuera
|
||||
if np.isnan(occ_pct):
|
||||
occ_penalty = 5.0
|
||||
else:
|
||||
deviation = abs(occ_pct - 80.0)
|
||||
occ_penalty = min(10.0, deviation / 5.0 * 2.0) # cada 5 puntos se suman 2, máx 10
|
||||
occ_score = max(0.0, 10.0 - occ_penalty)
|
||||
|
||||
# Escalación: 0-10 donde 0% -> 10 puntos, >=40% -> 0
|
||||
if np.isnan(esc_pct):
|
||||
esc_score = 5.0
|
||||
else:
|
||||
if esc_pct <= 0:
|
||||
esc_score = 10.0
|
||||
elif esc_pct >= 40:
|
||||
esc_score = 0.0
|
||||
else:
|
||||
esc_score = 10.0 * (1.0 - esc_pct / 40.0)
|
||||
|
||||
# Media simple de ambos
|
||||
return (occ_score + esc_score) / 2.0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Plots
|
||||
# ------------------------------------------------------------------ #
|
||||
def plot_aht_boxplot_by_skill(self) -> Axes:
|
||||
"""
|
||||
Boxplot del AHT por skill (P10-P50-P90 visual).
|
||||
"""
|
||||
df = self.df.copy()
|
||||
|
||||
if df.empty or "handle_time" not in df.columns:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin datos de AHT", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
df = df.dropna(subset=["handle_time"])
|
||||
if df.empty:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "AHT no disponible", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4))
|
||||
df.boxplot(column="handle_time", by="queue_skill", ax=ax, showfliers=False)
|
||||
|
||||
ax.set_xlabel("Skill / Cola")
|
||||
ax.set_ylabel("AHT (segundos)")
|
||||
ax.set_title("Distribución de AHT por skill")
|
||||
plt.suptitle("")
|
||||
plt.xticks(rotation=45, ha="right")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
|
||||
def plot_resolution_funnel_by_skill(self) -> Axes:
|
||||
"""
|
||||
Funnel / barras apiladas de Talk + Hold + ACW por skill (P50).
|
||||
|
||||
Permite ver el equilibrio de tiempos por skill.
|
||||
"""
|
||||
p50 = self.talk_hold_acw_p50_by_skill()
|
||||
if p50.empty:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin datos para funnel", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
|
||||
skills = p50.index
|
||||
talk = p50["talk_p50"]
|
||||
hold = p50["hold_p50"]
|
||||
acw = p50["acw_p50"]
|
||||
|
||||
x = np.arange(len(skills))
|
||||
|
||||
ax.bar(x, talk, label="Talk P50")
|
||||
ax.bar(x, hold, bottom=talk, label="Hold P50")
|
||||
ax.bar(x, acw, bottom=talk + hold, label="ACW P50")
|
||||
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(skills, rotation=45, ha="right")
|
||||
ax.set_ylabel("Segundos")
|
||||
ax.set_title("Funnel de resolución (P50) por skill")
|
||||
ax.legend()
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
298
backend/beyond_metrics/dimensions/SatisfactionExperience.py
Normal file
298
backend/beyond_metrics/dimensions/SatisfactionExperience.py
Normal file
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.axes import Axes
|
||||
|
||||
|
||||
# Solo columnas del dataset “core”
|
||||
REQUIRED_COLUMNS_SAT: List[str] = [
|
||||
"interaction_id",
|
||||
"datetime_start",
|
||||
"queue_skill",
|
||||
"channel",
|
||||
"duration_talk",
|
||||
"hold_time",
|
||||
"wrap_up_time",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SatisfactionExperienceMetrics:
|
||||
"""
|
||||
Dimensión 3: SATISFACCIÓN y EXPERIENCIA
|
||||
|
||||
Todas las columnas de satisfacción (csat/nps/ces/aht) son OPCIONALES.
|
||||
Si no están, las métricas que las usan devuelven vacío/NaN pero
|
||||
nunca rompen el pipeline.
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_columns()
|
||||
self._prepare_data()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
def _validate_columns(self) -> None:
|
||||
missing = [c for c in REQUIRED_COLUMNS_SAT if c not in self.df.columns]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Faltan columnas obligatorias para SatisfactionExperienceMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
df = self.df.copy()
|
||||
|
||||
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
|
||||
|
||||
# Duraciones base siempre existen
|
||||
for col in ["duration_talk", "hold_time", "wrap_up_time"]:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
# Handle time
|
||||
df["handle_time"] = (
|
||||
df["duration_talk"].fillna(0)
|
||||
+ df["hold_time"].fillna(0)
|
||||
+ df["wrap_up_time"].fillna(0)
|
||||
)
|
||||
|
||||
# csat_score opcional
|
||||
df["csat_score"] = pd.to_numeric(df.get("csat_score", np.nan), errors="coerce")
|
||||
|
||||
# aht opcional: si existe columna explícita la usamos, si no usamos handle_time
|
||||
if "aht" in df.columns:
|
||||
df["aht"] = pd.to_numeric(df["aht"], errors="coerce")
|
||||
else:
|
||||
df["aht"] = df["handle_time"]
|
||||
|
||||
# NPS / CES opcionales
|
||||
df["nps_score"] = pd.to_numeric(df.get("nps_score", np.nan), errors="coerce")
|
||||
df["ces_score"] = pd.to_numeric(df.get("ces_score", np.nan), errors="coerce")
|
||||
|
||||
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
||||
df["channel"] = df["channel"].astype(str).str.strip()
|
||||
|
||||
self.df = df
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.df.empty
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# KPIs
|
||||
# ------------------------------------------------------------------ #
|
||||
def csat_avg_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
CSAT promedio por skill/canal.
|
||||
Si no hay csat_score, devuelve DataFrame vacío.
|
||||
"""
|
||||
df = self.df
|
||||
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = df.dropna(subset=["csat_score"])
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
pivot = (
|
||||
df.pivot_table(
|
||||
index="queue_skill",
|
||||
columns="channel",
|
||||
values="csat_score",
|
||||
aggfunc="mean",
|
||||
)
|
||||
.sort_index()
|
||||
.round(2)
|
||||
)
|
||||
return pivot
|
||||
|
||||
def nps_avg_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
NPS medio por skill/canal, si existe nps_score.
|
||||
"""
|
||||
df = self.df
|
||||
if "nps_score" not in df.columns or df["nps_score"].notna().sum() == 0:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = df.dropna(subset=["nps_score"])
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
pivot = (
|
||||
df.pivot_table(
|
||||
index="queue_skill",
|
||||
columns="channel",
|
||||
values="nps_score",
|
||||
aggfunc="mean",
|
||||
)
|
||||
.sort_index()
|
||||
.round(2)
|
||||
)
|
||||
return pivot
|
||||
|
||||
def ces_avg_by_skill_channel(self) -> pd.DataFrame:
|
||||
"""
|
||||
CES medio por skill/canal, si existe ces_score.
|
||||
"""
|
||||
df = self.df
|
||||
if "ces_score" not in df.columns or df["ces_score"].notna().sum() == 0:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = df.dropna(subset=["ces_score"])
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
pivot = (
|
||||
df.pivot_table(
|
||||
index="queue_skill",
|
||||
columns="channel",
|
||||
values="ces_score",
|
||||
aggfunc="mean",
|
||||
)
|
||||
.sort_index()
|
||||
.round(2)
|
||||
)
|
||||
return pivot
|
||||
|
||||
def csat_aht_correlation(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Correlación Pearson CSAT vs AHT.
|
||||
Si falta csat o aht, o no hay varianza, devuelve NaN y código adecuado.
|
||||
"""
|
||||
df = self.df
|
||||
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
|
||||
return {"r": float("nan"), "n": 0.0, "interpretation_code": "sin_datos"}
|
||||
if "aht" not in df.columns or df["aht"].notna().sum() == 0:
|
||||
return {"r": float("nan"), "n": 0.0, "interpretation_code": "sin_datos"}
|
||||
|
||||
df = df.dropna(subset=["csat_score", "aht"]).copy()
|
||||
n = len(df)
|
||||
if n < 2:
|
||||
return {"r": float("nan"), "n": float(n), "interpretation_code": "insuficiente"}
|
||||
|
||||
x = df["aht"].astype(float)
|
||||
y = df["csat_score"].astype(float)
|
||||
|
||||
if x.std(ddof=1) == 0 or y.std(ddof=1) == 0:
|
||||
return {"r": float("nan"), "n": float(n), "interpretation_code": "sin_varianza"}
|
||||
|
||||
r = float(np.corrcoef(x, y)[0, 1])
|
||||
|
||||
if r < -0.3:
|
||||
interpretation = "negativo"
|
||||
elif r > 0.3:
|
||||
interpretation = "positivo"
|
||||
else:
|
||||
interpretation = "neutral"
|
||||
|
||||
return {"r": round(r, 3), "n": float(n), "interpretation_code": interpretation}
|
||||
|
||||
def csat_aht_skill_summary(self) -> pd.DataFrame:
|
||||
"""
|
||||
Resumen por skill con clasificación del "sweet spot".
|
||||
Si falta csat o aht, devuelve DataFrame vacío.
|
||||
"""
|
||||
df = self.df
|
||||
if df["csat_score"].notna().sum() == 0 or df["aht"].notna().sum() == 0:
|
||||
return pd.DataFrame(columns=["csat_avg", "aht_avg", "classification"])
|
||||
|
||||
df = df.dropna(subset=["csat_score", "aht"]).copy()
|
||||
if df.empty:
|
||||
return pd.DataFrame(columns=["csat_avg", "aht_avg", "classification"])
|
||||
|
||||
grouped = df.groupby("queue_skill").agg(
|
||||
csat_avg=("csat_score", "mean"),
|
||||
aht_avg=("aht", "mean"),
|
||||
)
|
||||
|
||||
aht_all = df["aht"].astype(float)
|
||||
csat_all = df["csat_score"].astype(float)
|
||||
|
||||
aht_p40 = float(np.percentile(aht_all, 40))
|
||||
aht_p60 = float(np.percentile(aht_all, 60))
|
||||
csat_p40 = float(np.percentile(csat_all, 40))
|
||||
csat_p60 = float(np.percentile(csat_all, 60))
|
||||
|
||||
def classify(row) -> str:
|
||||
csat = row["csat_avg"]
|
||||
aht = row["aht_avg"]
|
||||
|
||||
if aht <= aht_p40 and csat >= csat_p60:
|
||||
return "ideal_automatizar"
|
||||
if aht >= aht_p60 and csat >= csat_p40:
|
||||
return "requiere_humano"
|
||||
return "neutral"
|
||||
|
||||
grouped["classification"] = grouped.apply(classify, axis=1)
|
||||
return grouped.round({"csat_avg": 2, "aht_avg": 2})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Plots
|
||||
# ------------------------------------------------------------------ #
|
||||
def plot_csat_vs_aht_scatter(self) -> Axes:
|
||||
"""
|
||||
Scatter CSAT vs AHT por skill.
|
||||
Si no hay datos suficientes, devuelve un Axes con mensaje.
|
||||
"""
|
||||
df = self.df
|
||||
if df["csat_score"].notna().sum() == 0 or df["aht"].notna().sum() == 0:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin datos de CSAT/AHT", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
df = df.dropna(subset=["csat_score", "aht"]).copy()
|
||||
if df.empty:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin datos de CSAT/AHT", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 5))
|
||||
|
||||
for skill, sub in df.groupby("queue_skill"):
|
||||
ax.scatter(sub["aht"], sub["csat_score"], label=skill, alpha=0.7)
|
||||
|
||||
ax.set_xlabel("AHT (segundos)")
|
||||
ax.set_ylabel("CSAT")
|
||||
ax.set_title("CSAT vs AHT por skill")
|
||||
ax.grid(alpha=0.3)
|
||||
ax.legend(title="Skill", bbox_to_anchor=(1.05, 1), loc="upper left")
|
||||
|
||||
plt.tight_layout()
|
||||
return ax
|
||||
|
||||
def plot_csat_distribution(self) -> Axes:
|
||||
"""
|
||||
Histograma de CSAT.
|
||||
Si no hay csat_score, devuelve un Axes con mensaje.
|
||||
"""
|
||||
df = self.df
|
||||
if "csat_score" not in df.columns or df["csat_score"].notna().sum() == 0:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin datos de CSAT", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
df = df.dropna(subset=["csat_score"]).copy()
|
||||
if df.empty:
|
||||
fig, ax = plt.subplots()
|
||||
ax.text(0.5, 0.5, "Sin datos de CSAT", ha="center", va="center")
|
||||
ax.set_axis_off()
|
||||
return ax
|
||||
|
||||
fig, ax = plt.subplots(figsize=(6, 4))
|
||||
ax.hist(df["csat_score"], bins=10, alpha=0.7)
|
||||
ax.set_xlabel("CSAT")
|
||||
ax.set_ylabel("Frecuencia")
|
||||
ax.set_title("Distribución de CSAT")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
268
backend/beyond_metrics/dimensions/Volumetria.py
Normal file
268
backend/beyond_metrics/dimensions/Volumetria.py
Normal file
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.axes import Axes
|
||||
|
||||
|
||||
REQUIRED_COLUMNS_VOLUMETRIA: List[str] = [
|
||||
"interaction_id",
|
||||
"datetime_start",
|
||||
"queue_skill",
|
||||
"channel",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class VolumetriaMetrics:
|
||||
"""
|
||||
Métricas de volumetría basadas en el nuevo esquema de datos.
|
||||
|
||||
Columnas mínimas requeridas:
|
||||
- interaction_id
|
||||
- datetime_start
|
||||
- queue_skill
|
||||
- channel
|
||||
|
||||
Otras columnas pueden existir pero no son necesarias para estas métricas.
|
||||
"""
|
||||
|
||||
df: pd.DataFrame
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_columns()
|
||||
self._prepare_data()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers internos
|
||||
# ------------------------------------------------------------------ #
|
||||
def _validate_columns(self) -> None:
|
||||
missing = [c for c in REQUIRED_COLUMNS_VOLUMETRIA if c not in self.df.columns]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Faltan columnas obligatorias para VolumetriaMetrics: {missing}"
|
||||
)
|
||||
|
||||
def _prepare_data(self) -> None:
|
||||
df = self.df.copy()
|
||||
|
||||
# Asegurar tipo datetime
|
||||
df["datetime_start"] = pd.to_datetime(df["datetime_start"], errors="coerce")
|
||||
|
||||
# Normalizar strings
|
||||
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
||||
df["channel"] = df["channel"].astype(str).str.strip()
|
||||
|
||||
# Guardamos el df preparado
|
||||
self.df = df
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Propiedades útiles
|
||||
# ------------------------------------------------------------------ #
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.df.empty
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Métricas numéricas / tabulares
|
||||
# ------------------------------------------------------------------ #
|
||||
def volume_by_channel(self) -> pd.Series:
|
||||
"""
|
||||
Nº de interacciones por canal.
|
||||
"""
|
||||
return self.df.groupby("channel")["interaction_id"].nunique().sort_values(
|
||||
ascending=False
|
||||
)
|
||||
|
||||
def volume_by_skill(self) -> pd.Series:
|
||||
"""
|
||||
Nº de interacciones por skill / cola.
|
||||
"""
|
||||
return self.df.groupby("queue_skill")["interaction_id"].nunique().sort_values(
|
||||
ascending=False
|
||||
)
|
||||
|
||||
def channel_distribution_pct(self) -> pd.Series:
|
||||
"""
|
||||
Distribución porcentual del volumen por canal.
|
||||
"""
|
||||
counts = self.volume_by_channel()
|
||||
total = counts.sum()
|
||||
if total == 0:
|
||||
return counts * 0.0
|
||||
return (counts / total * 100).round(2)
|
||||
|
||||
def skill_distribution_pct(self) -> pd.Series:
|
||||
"""
|
||||
Distribución porcentual del volumen por skill.
|
||||
"""
|
||||
counts = self.volume_by_skill()
|
||||
total = counts.sum()
|
||||
if total == 0:
|
||||
return counts * 0.0
|
||||
return (counts / total * 100).round(2)
|
||||
|
||||
def heatmap_24x7(self) -> pd.DataFrame:
|
||||
"""
|
||||
Matriz [día_semana x hora] con nº de interacciones.
|
||||
dayofweek: 0=Lunes ... 6=Domingo
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df.empty:
|
||||
# Devolvemos un df vacío pero con índice/columnas esperadas
|
||||
idx = range(7)
|
||||
cols = range(24)
|
||||
return pd.DataFrame(0, index=idx, columns=cols)
|
||||
|
||||
df["dow"] = df["datetime_start"].dt.dayofweek
|
||||
df["hour"] = df["datetime_start"].dt.hour
|
||||
|
||||
pivot = (
|
||||
df.pivot_table(
|
||||
index="dow",
|
||||
columns="hour",
|
||||
values="interaction_id",
|
||||
aggfunc="nunique",
|
||||
fill_value=0,
|
||||
)
|
||||
.reindex(index=range(7), fill_value=0)
|
||||
.reindex(columns=range(24), fill_value=0)
|
||||
)
|
||||
|
||||
return pivot
|
||||
|
||||
def monthly_seasonality_cv(self) -> float:
|
||||
"""
|
||||
Coeficiente de variación del volumen mensual.
|
||||
CV = std / mean (en %).
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df.empty:
|
||||
return float("nan")
|
||||
|
||||
df["year_month"] = df["datetime_start"].dt.to_period("M")
|
||||
monthly_counts = (
|
||||
df.groupby("year_month")["interaction_id"].nunique().astype(float)
|
||||
)
|
||||
|
||||
if len(monthly_counts) < 2:
|
||||
return float("nan")
|
||||
|
||||
mean = monthly_counts.mean()
|
||||
std = monthly_counts.std(ddof=1)
|
||||
if mean == 0:
|
||||
return float("nan")
|
||||
|
||||
return float(round(std / mean * 100, 2))
|
||||
|
||||
def peak_offpeak_ratio(self) -> float:
|
||||
"""
|
||||
Ratio de volumen entre horas pico y valle.
|
||||
|
||||
Definimos pico como horas 10:00–19:59, resto valle.
|
||||
"""
|
||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||
if df.empty:
|
||||
return float("nan")
|
||||
|
||||
df["hour"] = df["datetime_start"].dt.hour
|
||||
|
||||
peak_hours = list(range(10, 20))
|
||||
is_peak = df["hour"].isin(peak_hours)
|
||||
|
||||
peak_vol = df.loc[is_peak, "interaction_id"].nunique()
|
||||
off_vol = df.loc[~is_peak, "interaction_id"].nunique()
|
||||
|
||||
if off_vol == 0:
|
||||
return float("inf") if peak_vol > 0 else float("nan")
|
||||
|
||||
return float(round(peak_vol / off_vol, 3))
|
||||
|
||||
def concentration_top20_skills_pct(self) -> float:
|
||||
"""
|
||||
% del volumen concentrado en el top 20% de skills (por nº de interacciones).
|
||||
"""
|
||||
counts = (
|
||||
self.df.groupby("queue_skill")["interaction_id"].nunique().sort_values(
|
||||
ascending=False
|
||||
)
|
||||
)
|
||||
|
||||
n_skills = len(counts)
|
||||
if n_skills == 0:
|
||||
return float("nan")
|
||||
|
||||
top_n = max(1, int(np.ceil(0.2 * n_skills)))
|
||||
top_vol = counts.head(top_n).sum()
|
||||
total = counts.sum()
|
||||
|
||||
if total == 0:
|
||||
return float("nan")
|
||||
|
||||
return float(round(top_vol / total * 100, 2))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Plots
|
||||
# ------------------------------------------------------------------ #
|
||||
def plot_heatmap_24x7(self) -> Axes:
|
||||
"""
|
||||
Heatmap de volumen por día de la semana (0-6) y hora (0-23).
|
||||
Devuelve Axes para que el pipeline pueda guardar la figura.
|
||||
"""
|
||||
data = self.heatmap_24x7()
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
im = ax.imshow(data.values, aspect="auto", origin="lower")
|
||||
|
||||
ax.set_xticks(range(24))
|
||||
ax.set_xticklabels([str(h) for h in range(24)])
|
||||
|
||||
ax.set_yticks(range(7))
|
||||
ax.set_yticklabels(["L", "M", "X", "J", "V", "S", "D"])
|
||||
|
||||
|
||||
ax.set_xlabel("Hora del día")
|
||||
ax.set_ylabel("Día de la semana")
|
||||
ax.set_title("Volumen por día de la semana y hora")
|
||||
|
||||
plt.colorbar(im, ax=ax, label="Nº interacciones")
|
||||
|
||||
return ax
|
||||
|
||||
def plot_channel_distribution(self) -> Axes:
|
||||
"""
|
||||
Distribución de volumen por canal.
|
||||
"""
|
||||
series = self.volume_by_channel()
|
||||
|
||||
fig, ax = plt.subplots(figsize=(6, 4))
|
||||
series.plot(kind="bar", ax=ax)
|
||||
|
||||
ax.set_xlabel("Canal")
|
||||
ax.set_ylabel("Nº interacciones")
|
||||
ax.set_title("Volumen por canal")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
return ax
|
||||
|
||||
def plot_skill_pareto(self) -> Axes:
|
||||
"""
|
||||
Pareto simple de volumen por skill (solo barras de volumen).
|
||||
"""
|
||||
series = self.volume_by_skill()
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
series.plot(kind="bar", ax=ax)
|
||||
|
||||
ax.set_xlabel("Skill / Cola")
|
||||
ax.set_ylabel("Nº interacciones")
|
||||
ax.set_title("Pareto de volumen por skill")
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
plt.xticks(rotation=45, ha="right")
|
||||
|
||||
return ax
|
||||
13
backend/beyond_metrics/dimensions/__init__.py
Normal file
13
backend/beyond_metrics/dimensions/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from .Volumetria import VolumetriaMetrics
|
||||
from .OperationalPerformance import OperationalPerformanceMetrics
|
||||
from .SatisfactionExperience import SatisfactionExperienceMetrics
|
||||
from .EconomyCost import EconomyCostMetrics, EconomyConfig
|
||||
|
||||
__all__ = [
|
||||
# Dimensiones
|
||||
"VolumetriaMetrics",
|
||||
"OperationalPerformanceMetrics",
|
||||
"SatisfactionExperienceMetrics",
|
||||
"EconomyCostMetrics",
|
||||
"EconomyConfig",
|
||||
]
|
||||
Reference in New Issue
Block a user