File size: 5,380 Bytes
81e5fe7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | """analyze_comparison β compare a metric across two groups (KM-608).
An analytical "family" tool: in ONE call it aggregates a value for two groups
of a dimension (e.g. region "A" vs "B", channel "online" vs "store") and
reports the gap between them β absolute difference, percent difference, and
direction. group_a is treated as the baseline. Answers questions like
"how does revenue in region A compare to region B?".
STATUS: compute layer only β the function takes an already-materialized
DataFrame. The wrapper layer (fetching data from the catalog via source_id,
the ToolOutput envelope, ToolSpec registration) is added once the Planner
seam (KM-418) is settled. Keeping compute separate from data-fetching makes
this function easy to unit-test in isolation and stable when wrapped.
"""
from __future__ import annotations
import pandas as pd
from src.tools.analytics.descriptive import ColumnNotFoundError
# How to aggregate the value within each group before comparing.
SUPPORTED_AGGS = ("sum", "mean", "count", "min", "max", "median")
class UnsupportedAggregationError(ValueError):
"""The requested aggregation is not supported (maps to error_code UNSUPPORTED_AGG)."""
class GroupNotFoundError(ValueError):
"""A requested group value does not occur in the dimension column (maps to GROUP_NOT_FOUND)."""
# Prompt-style description read by the Planner to decide WHEN to pick this tool.
# Final destination is ToolSpec.description once the wrapper layer is built.
DESCRIPTION = """\
Summary: Head-to-head comparison of one aggregated metric between TWO specific \
groups of a dimension (group_a is the baseline). Reports each group's value, \
the absolute and percent difference, and which side is higher.
USE WHEN the question pits two named groups against each other. Trigger words: \
"vs"/"versus", "compare" (bandingkan), "A or B", "difference between" \
(selisih/beda antara), "higher/lower than".
SETTING GROUPS: group_a is the BASELINE (the reference). The "comparison" field \
reads as "group_b is {higher/lower/equal} than group_a", and diff = value_b - \
value_a. Put the reference/older/expected side in group_a. E.g. "is this year \
higher than last year" -> group_a=last year, group_b=this year.
DON'T USE WHEN:
- it aggregates across many groups at once -> analyze_aggregate
- it splits a single total into shares -> analyze_contribution
- it tracks change over time -> analyze_trend
Example questions:
- "compare revenue between Jakarta and Surabaya"
- "is the average order value higher for members or non-members?"
- "difference in churn between plan A and plan B"
- "male vs female average spend"
"""
def analyze_comparison(
df: pd.DataFrame,
dimension: str,
value_column: str,
group_a: object,
group_b: object,
agg: str = "sum",
) -> dict[str, object]:
"""Compare one aggregated metric between two groups of a dimension.
Args:
df: already-materialized data (in the real system the wrapper fetches
this from a source_id).
dimension: the categorical column whose values define the two groups.
value_column: numeric column to aggregate for each group.
group_a: baseline group value (the "from").
group_b: comparison group value (the "to").
agg: how to aggregate within each group β one of SUPPORTED_AGGS.
Returns:
dict with:
dimension, value_column, agg β echo of the chosen settings
group_a, value_a β baseline group + its aggregate
group_b, value_b β comparison group + its aggregate
diff_abs β value_b - value_a
diff_pct β diff_abs / value_a, or None if value_a == 0
comparison β "higher" | "lower" | "equal" (b relative to a)
Raises:
ColumnNotFoundError: if dimension or value_column is absent.
UnsupportedAggregationError: if agg is not supported.
GroupNotFoundError: if group_a or group_b has no rows.
"""
missing = [c for c in (dimension, value_column) if c not in df.columns]
if missing:
raise ColumnNotFoundError(f"columns not found: {missing}")
if agg not in SUPPORTED_AGGS:
raise UnsupportedAggregationError(
f"unsupported aggregation '{agg}'; supported: {list(SUPPORTED_AGGS)}"
)
rows_a = df.loc[df[dimension] == group_a, value_column]
rows_b = df.loc[df[dimension] == group_b, value_column]
empty = [g for g, rows in ((group_a, rows_a), (group_b, rows_b)) if rows.empty]
if empty:
raise GroupNotFoundError(
f"no rows for group(s) {empty} in column '{dimension}'"
)
value_a = float(rows_a.agg(agg))
value_b = float(rows_b.agg(agg))
diff_abs = value_b - value_a
diff_pct = (diff_abs / value_a) if value_a != 0 else None
if diff_abs > 0:
comparison = "higher"
elif diff_abs < 0:
comparison = "lower"
else:
comparison = "equal"
return {
"dimension": dimension,
"value_column": value_column,
"agg": agg,
"group_a": group_a,
"value_a": value_a,
"group_b": group_b,
"value_b": value_b,
"diff_abs": diff_abs,
"diff_pct": diff_pct,
"comparison": comparison,
}
|