openretailscience 0.45.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openretailscience/__init__.py +3 -0
- openretailscience/analysis/__init__.py +0 -0
- openretailscience/analysis/cohort.py +185 -0
- openretailscience/analysis/composite_rank.py +372 -0
- openretailscience/analysis/cross_shop.py +354 -0
- openretailscience/analysis/customer.py +459 -0
- openretailscience/analysis/customer_decision_hierarchy.py +359 -0
- openretailscience/analysis/gain_loss.py +340 -0
- openretailscience/analysis/haversine.py +50 -0
- openretailscience/analysis/product_association.py +418 -0
- openretailscience/analysis/revenue_tree.py +553 -0
- openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
- openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
- openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
- openretailscience/constants.py +295 -0
- openretailscience/metrics/__init__.py +0 -0
- openretailscience/metrics/base.py +29 -0
- openretailscience/metrics/distribution/__init__.py +0 -0
- openretailscience/metrics/distribution/acv.py +81 -0
- openretailscience/metrics/distribution/pct_of_stores.py +118 -0
- openretailscience/options.py +842 -0
- openretailscience/plots/__init__.py +0 -0
- openretailscience/plots/area.py +114 -0
- openretailscience/plots/bar.py +375 -0
- openretailscience/plots/broken_timeline.py +197 -0
- openretailscience/plots/cohort.py +94 -0
- openretailscience/plots/heatmap.py +134 -0
- openretailscience/plots/histogram.py +276 -0
- openretailscience/plots/index.py +505 -0
- openretailscience/plots/line.py +379 -0
- openretailscience/plots/period_on_period.py +144 -0
- openretailscience/plots/price.py +275 -0
- openretailscience/plots/scatter.py +382 -0
- openretailscience/plots/styles/__init__.py +1 -0
- openretailscience/plots/styles/colors.py +215 -0
- openretailscience/plots/styles/font_utils.py +62 -0
- openretailscience/plots/styles/graph_utils.py +834 -0
- openretailscience/plots/styles/styling_helpers.py +155 -0
- openretailscience/plots/time.py +160 -0
- openretailscience/plots/tree_diagram.py +813 -0
- openretailscience/plots/venn.py +127 -0
- openretailscience/plots/waterfall.py +217 -0
- openretailscience/plugin.py +238 -0
- openretailscience/segmentation/__init__.py +0 -0
- openretailscience/segmentation/hml.py +97 -0
- openretailscience/segmentation/nlr.py +218 -0
- openretailscience/segmentation/rfm.py +307 -0
- openretailscience/segmentation/segstats.py +1177 -0
- openretailscience/segmentation/threshold.py +165 -0
- openretailscience/utils/__init__.py +0 -0
- openretailscience/utils/date.py +226 -0
- openretailscience/utils/filter_and_label.py +52 -0
- openretailscience/utils/label.py +79 -0
- openretailscience/utils/validation.py +45 -0
- openretailscience-0.45.0.dist-info/METADATA +364 -0
- openretailscience-0.45.0.dist-info/RECORD +60 -0
- openretailscience-0.45.0.dist-info/WHEEL +4 -0
- openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Threshold-Based Segmentation for Flexible Business Classification.
|
|
2
|
+
|
|
3
|
+
## Business Context
|
|
4
|
+
|
|
5
|
+
Many retail segmentation needs don't fit standard models like RFM. Businesses often need
|
|
6
|
+
custom segments based on specific metrics and thresholds - whether segmenting customers
|
|
7
|
+
by spend percentiles, stores by performance quintiles, or products by sales velocity.
|
|
8
|
+
This module provides flexible threshold-based segmentation for any business dimension.
|
|
9
|
+
|
|
10
|
+
## The Business Problem
|
|
11
|
+
|
|
12
|
+
Retailers need custom segmentation rules for different business scenarios:
|
|
13
|
+
- Create spend-based customer tiers (Bronze, Silver, Gold, Platinum)
|
|
14
|
+
- Classify stores into performance bands (A, B, C, D stores)
|
|
15
|
+
- Segment products by velocity (Fast, Medium, Slow movers)
|
|
16
|
+
- Define custom categories based on business-specific thresholds
|
|
17
|
+
|
|
18
|
+
Standard segmentation approaches are too rigid, while manual classification is inconsistent
|
|
19
|
+
and doesn't scale across large datasets.
|
|
20
|
+
|
|
21
|
+
## Real-World Applications
|
|
22
|
+
|
|
23
|
+
### Customer Classification
|
|
24
|
+
- Create VIP tiers based on total spend percentiles
|
|
25
|
+
- Segment by transaction frequency for different service levels
|
|
26
|
+
- Classify customers by recency for retention campaigns
|
|
27
|
+
|
|
28
|
+
### Store Performance Tiers
|
|
29
|
+
- Classify stores by sales per square foot into performance bands
|
|
30
|
+
- Segment locations by customer conversion rates
|
|
31
|
+
- Create store tiers for investment prioritization
|
|
32
|
+
|
|
33
|
+
### Product Categorization
|
|
34
|
+
- Segment SKUs by sales velocity for inventory management
|
|
35
|
+
- Classify products by margin contribution for pricing strategies
|
|
36
|
+
- Create ABC analysis categories for supply chain optimization
|
|
37
|
+
|
|
38
|
+
## Technical Features
|
|
39
|
+
|
|
40
|
+
- Flexible percentile-based thresholds for consistent segment sizing
|
|
41
|
+
- Custom aggregation functions for different business metrics
|
|
42
|
+
- Configurable handling of zero-value entities
|
|
43
|
+
- Efficient execution using Ibis for large datasets
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from typing import Literal
|
|
47
|
+
|
|
48
|
+
import ibis
|
|
49
|
+
import pandas as pd
|
|
50
|
+
|
|
51
|
+
from openretailscience.options import ColumnHelper
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ThresholdSegmentation:
|
|
55
|
+
"""Segments customers based on user-defined thresholds and segments."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
df: pd.DataFrame | ibis.Table,
|
|
60
|
+
thresholds: list[float],
|
|
61
|
+
segments: list[str],
|
|
62
|
+
value_col: str | None = None,
|
|
63
|
+
agg_func: str = "sum",
|
|
64
|
+
zero_segment_name: str = "Zero",
|
|
65
|
+
zero_value_customers: Literal["separate_segment", "exclude", "include_with_light"] = "separate_segment",
|
|
66
|
+
group_col: str | list[str] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Segments customers based on user-defined thresholds and segments.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
df (pd.DataFrame | ibis.Table): A dataframe with the transaction data. The dataframe must contain a
|
|
72
|
+
customer_id column.
|
|
73
|
+
thresholds (List[float]): The percentile thresholds for segmentation.
|
|
74
|
+
segments (List[str]): A list of segment names for each threshold.
|
|
75
|
+
value_col (str, optional): The column to use for the segmentation. Defaults to
|
|
76
|
+
ColumnHelper().unit_spend.
|
|
77
|
+
agg_func (str, optional): The aggregation function to use when grouping by customer_id. Defaults to "sum".
|
|
78
|
+
zero_segment_name (str, optional): The name of the segment for customers with zero spend.
|
|
79
|
+
Defaults to "Zero".
|
|
80
|
+
zero_value_customers (Literal["separate_segment", "exclude", "include_with_light"], optional): How to handle
|
|
81
|
+
customers with zero spend. Defaults to "separate_segment".
|
|
82
|
+
group_col (str | list[str] | None, optional): Column(s) to group by when calculating segments. When
|
|
83
|
+
specified, segments are calculated within each group independently. For example, setting
|
|
84
|
+
group_col="store_id" calculates Heavy/Medium/Light segments within each store. Defaults to None.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the dataframe is missing the columns option column.customer_id or `value_col`, or these
|
|
88
|
+
columns contain null values.
|
|
89
|
+
"""
|
|
90
|
+
self._df: pd.DataFrame | None = None
|
|
91
|
+
self._group_col: list[str] | None = None
|
|
92
|
+
if len(thresholds) != len(set(thresholds)):
|
|
93
|
+
raise ValueError("The thresholds must be unique")
|
|
94
|
+
|
|
95
|
+
if len(thresholds) != len(segments):
|
|
96
|
+
raise ValueError("The number of thresholds must match the number of segments")
|
|
97
|
+
|
|
98
|
+
# Initialize column helper
|
|
99
|
+
cols = ColumnHelper()
|
|
100
|
+
|
|
101
|
+
# Normalize group_col to a list
|
|
102
|
+
self._group_col = [group_col] if isinstance(group_col, str) else group_col
|
|
103
|
+
|
|
104
|
+
if isinstance(df, pd.DataFrame):
|
|
105
|
+
df: ibis.Table = ibis.memtable(df)
|
|
106
|
+
|
|
107
|
+
value_col = cols.unit_spend if value_col is None else value_col
|
|
108
|
+
|
|
109
|
+
required_cols = [cols.customer_id, value_col]
|
|
110
|
+
if self._group_col is not None:
|
|
111
|
+
required_cols.extend(self._group_col)
|
|
112
|
+
|
|
113
|
+
missing_cols = set(required_cols) - set(df.columns)
|
|
114
|
+
if len(missing_cols) > 0:
|
|
115
|
+
msg = f"The following columns are required but missing: {missing_cols}"
|
|
116
|
+
raise ValueError(msg)
|
|
117
|
+
|
|
118
|
+
# Build group_by columns: customer_id + optional group columns
|
|
119
|
+
group_by_cols = [cols.customer_id]
|
|
120
|
+
if self._group_col is not None:
|
|
121
|
+
group_by_cols.extend(self._group_col)
|
|
122
|
+
|
|
123
|
+
df = df.group_by(*group_by_cols).aggregate(
|
|
124
|
+
**{value_col: getattr(df[value_col], agg_func)()},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Separate customers with zero spend
|
|
128
|
+
zero_df = None
|
|
129
|
+
if zero_value_customers == "exclude":
|
|
130
|
+
df = df.filter(df[value_col] != 0)
|
|
131
|
+
elif zero_value_customers == "separate_segment":
|
|
132
|
+
zero_df = df.filter(df[value_col] == 0).mutate(segment_name=ibis.literal(zero_segment_name))
|
|
133
|
+
df = df.filter(df[value_col] != 0)
|
|
134
|
+
|
|
135
|
+
# Create window function, partitioned by group columns if specified
|
|
136
|
+
# Order by value_col first, then customer_id to ensure deterministic ordering when values are tied
|
|
137
|
+
window = (
|
|
138
|
+
ibis.window(order_by=[ibis.asc(df[value_col]), ibis.asc(df[cols.customer_id])])
|
|
139
|
+
if self._group_col is None
|
|
140
|
+
else ibis.window(
|
|
141
|
+
order_by=[ibis.asc(df[value_col]), ibis.asc(df[cols.customer_id])],
|
|
142
|
+
group_by=self._group_col,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
df = df.mutate(ptile=ibis.percent_rank().over(window))
|
|
146
|
+
|
|
147
|
+
case_args = [(df["ptile"] <= quantile, segment) for quantile, segment in zip(thresholds, segments, strict=True)]
|
|
148
|
+
|
|
149
|
+
df = df.mutate(segment_name=ibis.cases(*case_args)).drop(["ptile"])
|
|
150
|
+
|
|
151
|
+
if zero_value_customers == "separate_segment":
|
|
152
|
+
df = ibis.union(df, zero_df)
|
|
153
|
+
|
|
154
|
+
self.table = df
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def df(self) -> pd.DataFrame:
|
|
158
|
+
"""Returns the dataframe with the segment names."""
|
|
159
|
+
if self._df is None:
|
|
160
|
+
cols = ColumnHelper()
|
|
161
|
+
index_cols = [cols.customer_id]
|
|
162
|
+
if self._group_col is not None:
|
|
163
|
+
index_cols.extend(self._group_col)
|
|
164
|
+
self._df = self.table.execute().set_index(index_cols)
|
|
165
|
+
return self._df
|
|
File without changes
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Utility functions for time-related operations in retail analysis."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
import ibis
|
|
7
|
+
|
|
8
|
+
from openretailscience.options import get_option
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize_datetime(date_val: datetime | str) -> datetime:
|
|
12
|
+
"""Convert a string or datetime to a timezone-aware datetime object.
|
|
13
|
+
|
|
14
|
+
Strings are parsed as ``%Y-%m-%d`` and localized to UTC. Naive datetimes
|
|
15
|
+
are made aware by attaching UTC. Already-aware datetimes are returned
|
|
16
|
+
unchanged.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
date_val (datetime | str): A date string (``YYYY-MM-DD``) or datetime to normalize.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
datetime: A timezone-aware datetime in UTC.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
TypeError: If *date_val* is neither a ``str`` nor a ``datetime``.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(date_val, str):
|
|
28
|
+
# Convert string to timezone-aware datetime
|
|
29
|
+
return datetime.strptime(date_val, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
30
|
+
if isinstance(date_val, datetime):
|
|
31
|
+
# If datetime is timezone-naive, make it timezone-aware (UTC)
|
|
32
|
+
if date_val.tzinfo is None:
|
|
33
|
+
return date_val.replace(tzinfo=timezone.utc)
|
|
34
|
+
return date_val
|
|
35
|
+
error_msg = f"Expected str or datetime, got {type(date_val)}"
|
|
36
|
+
raise TypeError(error_msg)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_naive(d: datetime | str) -> bool:
|
|
40
|
+
"""Check whether a datetime-like input is timezone-naive.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
d (datetime | str): A datetime object or date string to check.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
bool: True if the input is a string or a naive datetime, False if tz-aware.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
TypeError: If d is not a str or datetime instance.
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(d, str):
|
|
52
|
+
return True
|
|
53
|
+
if isinstance(d, datetime):
|
|
54
|
+
return d.tzinfo is None
|
|
55
|
+
msg = f"Expected str or datetime, got {type(d)}"
|
|
56
|
+
raise TypeError(msg)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_and_normalize_periods(
|
|
60
|
+
period_ranges: Mapping[str, tuple[datetime | str, datetime | str]],
|
|
61
|
+
) -> dict[str, tuple[datetime, datetime]]:
|
|
62
|
+
"""Validates and normalizes period ranges, returning timezone-aware datetime tuples."""
|
|
63
|
+
normalized = {}
|
|
64
|
+
|
|
65
|
+
for period_name, date_range in period_ranges.items():
|
|
66
|
+
date_range_length = 2
|
|
67
|
+
if not (isinstance(date_range, tuple) and len(date_range) == date_range_length):
|
|
68
|
+
msg = f"Period '{period_name}' must have a (start_date, end_date) tuple"
|
|
69
|
+
raise ValueError(msg)
|
|
70
|
+
|
|
71
|
+
start_date, end_date = date_range
|
|
72
|
+
|
|
73
|
+
start_dt = _normalize_datetime(start_date)
|
|
74
|
+
end_dt = _normalize_datetime(end_date)
|
|
75
|
+
|
|
76
|
+
if start_dt > end_dt:
|
|
77
|
+
msg = f"Period '{period_name}': start date ({start_date}) must be <= end date ({end_date})"
|
|
78
|
+
raise ValueError(msg)
|
|
79
|
+
|
|
80
|
+
normalized[period_name] = (start_dt, end_dt)
|
|
81
|
+
|
|
82
|
+
# Check for overlapping periods
|
|
83
|
+
period_list = list(normalized.items())
|
|
84
|
+
for i, (name1, (start1, end1)) in enumerate(period_list):
|
|
85
|
+
for name2, (start2, end2) in period_list[i + 1 :]:
|
|
86
|
+
if start1 <= end2 and start2 <= end1:
|
|
87
|
+
overlap_msg = (
|
|
88
|
+
f"Periods '{name1}' ({start1.date()}-{end1.date()}) and"
|
|
89
|
+
f" '{name2}' ({start2.date()}-{end2.date()}) overlap"
|
|
90
|
+
)
|
|
91
|
+
raise ValueError(overlap_msg)
|
|
92
|
+
|
|
93
|
+
return normalized
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def filter_and_label_by_periods(
|
|
97
|
+
transactions: ibis.Table,
|
|
98
|
+
period_ranges: dict[str, tuple[datetime, datetime] | tuple[str, str]],
|
|
99
|
+
period_col: str = "period_name",
|
|
100
|
+
) -> ibis.Table:
|
|
101
|
+
"""Filters transactions to specified time periods and adds period labels.
|
|
102
|
+
|
|
103
|
+
This function filters transactions based on specified time periods and adds a new column indicating the period name.
|
|
104
|
+
It is useful for analyzing transactions within specific date ranges and comparing KPIs between them.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
transactions = ibis.table("transactions")
|
|
108
|
+
period_ranges = {
|
|
109
|
+
"Q1": ("2023-01-01", "2023-03-31"),
|
|
110
|
+
"Q2": ("2023-04-01", "2023-06-30"),
|
|
111
|
+
}
|
|
112
|
+
filtered_transactions = filter_and_label_by_periods(transactions, period_ranges)
|
|
113
|
+
# filtered_transactions will only contain transactions from the date ranges specified in Q1 and Q2 and a new
|
|
114
|
+
# column 'period_name' will be in the table defining the period for each transaction.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
transactions (ibis.Table): An ibis table with a transaction_date column.
|
|
118
|
+
period_ranges (dict[str, tuple[datetime, datetime] | tuple[str, str]]): Dict where keys are period names and
|
|
119
|
+
values are(start_date, end_date) tuples.
|
|
120
|
+
period_col (str, optional): Name of the column to create for period labels. Defaults to "period_name".
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
An ibis table with filtered transactions and added period label column.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If any value in period_ranges is not a tuple of length 2.
|
|
127
|
+
ValueError: If first date > second date for any period.
|
|
128
|
+
ValueError: If periods overlap with each other.
|
|
129
|
+
"""
|
|
130
|
+
# Validate periods first
|
|
131
|
+
_validate_and_normalize_periods(period_ranges)
|
|
132
|
+
|
|
133
|
+
branches = []
|
|
134
|
+
date_column = transactions[get_option("column.transaction_date")]
|
|
135
|
+
|
|
136
|
+
date_col_dtype = date_column.type()
|
|
137
|
+
|
|
138
|
+
for period_name, date_range in period_ranges.items():
|
|
139
|
+
start_date, end_date = date_range
|
|
140
|
+
|
|
141
|
+
period_condition = date_column.between(
|
|
142
|
+
ibis.literal(start_date, type=date_col_dtype),
|
|
143
|
+
ibis.literal(end_date, type=date_col_dtype),
|
|
144
|
+
)
|
|
145
|
+
branches.append((period_condition, ibis.literal(period_name)))
|
|
146
|
+
|
|
147
|
+
conditions = ibis.or_(*[condition[0] for condition in branches])
|
|
148
|
+
return transactions.filter(conditions).mutate(**{period_col: ibis.cases(*branches)})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def find_overlapping_periods(
|
|
152
|
+
start_date: datetime | str,
|
|
153
|
+
end_date: datetime | str,
|
|
154
|
+
return_str: bool = True,
|
|
155
|
+
) -> list[tuple[str | datetime, str | datetime]]:
|
|
156
|
+
"""Find overlapping time periods within the given date range, split by year.
|
|
157
|
+
|
|
158
|
+
This function generates overlapping periods between a given start date and end date.
|
|
159
|
+
The first period will start from the given start date, and each subsequent period will start on
|
|
160
|
+
the same month and day for the following years, ending each period on the same month and day
|
|
161
|
+
of the end date but in the subsequent year,
|
|
162
|
+
except for the last period, which ends at the provided end date.
|
|
163
|
+
|
|
164
|
+
Note:
|
|
165
|
+
This function does not adjust for leap years. If the start or end date is February 29,
|
|
166
|
+
it may cause an issue in non-leap years.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
start_date (datetime | str): The starting date of the range, either as a
|
|
170
|
+
datetime object or 'YYYY-MM-DD' string.
|
|
171
|
+
end_date (datetime | str): The ending date of the range, either as a
|
|
172
|
+
datetime object or 'YYYY-MM-DD' string.
|
|
173
|
+
return_str (bool, optional): If True, returns dates as ISO-formatted strings ('YYYY-MM-DD').
|
|
174
|
+
If False, returns datetime objects. Defaults to True.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
list[tuple[str | datetime, str | datetime]]:
|
|
178
|
+
A list of tuples where each tuple contains the start and end dates of an overlapping period,
|
|
179
|
+
either as strings (ISO format) or datetime objects. Returned datetimes preserve the
|
|
180
|
+
timezone-awareness of the input (naive in → naive out, aware in → aware out).
|
|
181
|
+
String inputs produce naive datetime outputs.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
TypeError: If start_date and end_date have mismatched timezone awareness
|
|
185
|
+
(one naive or string and one timezone-aware, or vice versa).
|
|
186
|
+
ValueError: If the start date is after the end date.
|
|
187
|
+
"""
|
|
188
|
+
# Track whether outputs should be tz-naive to preserve backward compatibility.
|
|
189
|
+
# String inputs and naive datetime inputs both produced naive outputs before.
|
|
190
|
+
start_is_naive = _is_naive(start_date)
|
|
191
|
+
end_is_naive = _is_naive(end_date)
|
|
192
|
+
|
|
193
|
+
if start_is_naive != end_is_naive:
|
|
194
|
+
msg = "start_date and end_date must have matching timezone awareness. Got naive and aware (or vice versa)."
|
|
195
|
+
raise TypeError(msg)
|
|
196
|
+
|
|
197
|
+
start_date = _normalize_datetime(start_date)
|
|
198
|
+
end_date = _normalize_datetime(end_date)
|
|
199
|
+
|
|
200
|
+
if start_date > end_date:
|
|
201
|
+
raise ValueError("Start date must be before end date")
|
|
202
|
+
|
|
203
|
+
if start_date.year == end_date.year:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
if start_is_naive:
|
|
207
|
+
output_tz = None
|
|
208
|
+
start_date = start_date.replace(tzinfo=None)
|
|
209
|
+
else:
|
|
210
|
+
output_tz = start_date.tzinfo
|
|
211
|
+
|
|
212
|
+
period_starts = [
|
|
213
|
+
start_date if year == start_date.year else datetime(year, start_date.month, start_date.day, tzinfo=output_tz)
|
|
214
|
+
for year in range(start_date.year, end_date.year)
|
|
215
|
+
]
|
|
216
|
+
period_ends = [
|
|
217
|
+
datetime(year + 1, end_date.month, end_date.day, tzinfo=output_tz)
|
|
218
|
+
for year in range(start_date.year, end_date.year)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
if return_str:
|
|
222
|
+
return [
|
|
223
|
+
(s.strftime("%Y-%m-%d"), e.strftime("%Y-%m-%d")) for s, e in zip(period_starts, period_ends, strict=True)
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
return list(zip(period_starts, period_ends, strict=True))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""filter_and_label Utilities - Filtering and Labeling by Condition.
|
|
2
|
+
|
|
3
|
+
This module provides utilities to filter an Ibis table based on arbitrary logical conditions
|
|
4
|
+
and attach descriptive labels to matched rows. It is useful for segmenting and analyzing data
|
|
5
|
+
according to business-defined rules, such as category classification, customer segmentation,
|
|
6
|
+
or pricing tiers.
|
|
7
|
+
|
|
8
|
+
Example use cases:
|
|
9
|
+
- Tagging product records based on category.
|
|
10
|
+
- Classifying transactions into business segments.
|
|
11
|
+
- Preparing labeled datasets for analysis or machine learning.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import ibis
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def filter_and_label_by_condition(
|
|
18
|
+
table: ibis.Table,
|
|
19
|
+
conditions: dict[str, ibis.expr.types.BooleanColumn],
|
|
20
|
+
label_col: str = "label",
|
|
21
|
+
) -> ibis.Table:
|
|
22
|
+
"""Filters a table based on specified conditions and adds labels.
|
|
23
|
+
|
|
24
|
+
This function filters rows in a table based on specified conditions and adds a new column
|
|
25
|
+
indicating the label associated with each condition. It's useful for categorizing data
|
|
26
|
+
based on different criteria.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
data = ibis.memtable(df)
|
|
30
|
+
labeled_data = filter_and_label_by_condition(
|
|
31
|
+
data,
|
|
32
|
+
conditions={
|
|
33
|
+
"toys": data["category"] == "toys",
|
|
34
|
+
"shoes": data["category"] == "shoes"
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
# labeled_data will only contain rows where category is either "toys" or "shoes",
|
|
38
|
+
# and a new column 'label' will be added indicating which category it belongs to.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
table (ibis.Table): An ibis table to filter.
|
|
42
|
+
conditions (dict[str, ibis.expr.types.BooleanColumn]): Dict where keys are labels and
|
|
43
|
+
values are ibis boolean expressions representing filter conditions.
|
|
44
|
+
label_col (str, optional): Name of the label column to add. Defaults to "label".
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
ibis.Table: An ibis table with filtered rows and an added label column.
|
|
48
|
+
"""
|
|
49
|
+
branches = [(condition, ibis.literal(label)) for label, condition in conditions.items()]
|
|
50
|
+
combined_condition = ibis.or_(*[condition for condition, _ in branches])
|
|
51
|
+
|
|
52
|
+
return table.filter(combined_condition).mutate(**{label_col: ibis.cases(*branches)})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Label Utilities - Labeling by Condition.
|
|
2
|
+
|
|
3
|
+
This module provides utilities to label groups in an Ibis table based on whether
|
|
4
|
+
any items in the group meet a specified condition. It supports both binary labeling
|
|
5
|
+
(contains/not_contains) and extended labeling (contains/mixed/not_contains).
|
|
6
|
+
|
|
7
|
+
Example use cases:
|
|
8
|
+
- Tag transactions as containing a product, product category, or promotion
|
|
9
|
+
- Tag customers as containing a product, product category, or promotion, or store_id
|
|
10
|
+
- Tag a transaction as containing promo items, no promo items, or both on/off promo items
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import ibis
|
|
16
|
+
from ibis import _
|
|
17
|
+
|
|
18
|
+
from openretailscience.options import get_option
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def label_by_condition(
|
|
22
|
+
table: ibis.Table,
|
|
23
|
+
condition: ibis.expr.types.BooleanColumn,
|
|
24
|
+
label_col: str | None = None,
|
|
25
|
+
return_col: str = "label_name",
|
|
26
|
+
labeling_strategy: Literal["binary", "extended"] = "binary",
|
|
27
|
+
contains_label: str | ibis.Value = "contains",
|
|
28
|
+
not_contains_label: str | ibis.Value = "not_contains",
|
|
29
|
+
mixed_label: str | ibis.Value = "mixed",
|
|
30
|
+
) -> ibis.Table:
|
|
31
|
+
"""Labels groups in a table based on whether items in the group meet a condition.
|
|
32
|
+
|
|
33
|
+
This function groups a table by the specified label column and determines whether
|
|
34
|
+
any items in each group meet the given condition. It supports two labeling strategies:
|
|
35
|
+
- "binary": Labels groups as either "contains" or "not_contains" based on whether
|
|
36
|
+
any item in the group meets the condition
|
|
37
|
+
- "extended": Labels groups as "contains" (all items meet condition), "mixed"
|
|
38
|
+
(some items meet condition), or "not_contains" (no items meet condition)
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
table (ibis.Table): An ibis table to process.
|
|
42
|
+
condition (ibis.expr.types.BooleanColumn): Boolean expression representing
|
|
43
|
+
the condition to evaluate.
|
|
44
|
+
label_col (str | None, optional): Column name to group by for labeling. If None, defaults to the setting
|
|
45
|
+
column.customer_id.
|
|
46
|
+
return_col (str, optional): Name of the column to add with the labels. Defaults to "label_name".
|
|
47
|
+
labeling_strategy (Literal["binary", "extended"], optional): Strategy for labeling groups.
|
|
48
|
+
Defaults to "binary".
|
|
49
|
+
contains_label (str | ibis.Value, optional): Label for groups that contain
|
|
50
|
+
items meeting the condition. Defaults to "contains".
|
|
51
|
+
not_contains_label (str | ibis.Value, optional): Label for groups that do not
|
|
52
|
+
contain items meeting the condition. Defaults to "not_contains".
|
|
53
|
+
mixed_label (str | ibis.Value, optional): Label for groups with mixed results
|
|
54
|
+
(only used with "extended" strategy). Defaults to "mixed".
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
ibis.Table: An ibis table grouped by label_col with an added label column.
|
|
58
|
+
"""
|
|
59
|
+
if label_col is None:
|
|
60
|
+
label_col = get_option("column.customer_id")
|
|
61
|
+
|
|
62
|
+
table = table.mutate(label_condition=condition.ifelse(1, 0)).group_by(label_col)
|
|
63
|
+
|
|
64
|
+
if labeling_strategy == "binary":
|
|
65
|
+
return table.aggregate(
|
|
66
|
+
{
|
|
67
|
+
return_col: (_.label_condition.max() == 1).ifelse(contains_label, not_contains_label),
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return table.aggregate(
|
|
72
|
+
{
|
|
73
|
+
return_col: ibis.cases(
|
|
74
|
+
((_.label_condition.max() == 1) & (_.label_condition.min() == 1), contains_label),
|
|
75
|
+
((_.label_condition.max() == 1) & (_.label_condition.min() == 0), mixed_label),
|
|
76
|
+
else_=not_contains_label,
|
|
77
|
+
),
|
|
78
|
+
},
|
|
79
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared validation utilities for OpenRetailScience."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ibis
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ensure_ibis_table(df: pd.DataFrame | ibis.Table) -> ibis.Table:
|
|
10
|
+
"""Convert pandas DataFrame to ibis Table, or validate input is an ibis Table.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
df (pd.DataFrame | ibis.Table): Input data to convert or validate.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
ibis.Table: An ibis Table representation of the input data.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
TypeError: If df is neither a pandas DataFrame nor an ibis Table.
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(df, pd.DataFrame):
|
|
22
|
+
return ibis.memtable(df)
|
|
23
|
+
if isinstance(df, ibis.Table):
|
|
24
|
+
return df
|
|
25
|
+
raise TypeError("df must be either a pandas DataFrame or an Ibis Table.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_columns(df: pd.DataFrame | ibis.Table, required_cols: list[str]) -> None:
|
|
29
|
+
"""Validates that a DataFrame or Ibis Table contains all required columns.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
df (pd.DataFrame | ibis.Table): The data to validate.
|
|
33
|
+
required_cols (list[str]): Column names that must be present.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
TypeError: If required_cols is not a list.
|
|
37
|
+
ValueError: If any required columns are missing from the data.
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(required_cols, list):
|
|
40
|
+
msg = "required_cols must be a list of column names."
|
|
41
|
+
raise TypeError(msg)
|
|
42
|
+
missing_cols = sorted(set(required_cols) - set(df.columns))
|
|
43
|
+
if len(missing_cols) > 0:
|
|
44
|
+
msg = f"The following columns are required but missing: {missing_cols}"
|
|
45
|
+
raise ValueError(msg)
|