pyretailscience 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/PKG-INFO +8 -3
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/README.md +5 -2
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyproject.toml +3 -1
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyretailscience/customer.py +61 -21
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyretailscience/data/simulation.py +3 -1
- pyretailscience-0.3.0/pyretailscience/range_planning.py +310 -0
- pyretailscience-0.3.0/pyretailscience/segmentation.py +212 -0
- pyretailscience-0.3.0/pyretailscience/standard_graphs.py +96 -0
- pyretailscience-0.3.0/pyretailscience/style/graph_utils.py +83 -0
- pyretailscience-0.2.0/pyretailscience/style/graph_utils.py +0 -37
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/LICENSE +0 -0
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyretailscience/__init__.py +0 -0
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyretailscience/data/__init__.py +0 -0
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyretailscience/data/cli.py +0 -0
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyretailscience/data/contracts.py +0 -0
- {pyretailscience-0.2.0 → pyretailscience-0.3.0}/pyretailscience/style/tailwind.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pyretailscience
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Retail Data Science Tools
|
|
5
5
|
License: Elastic-2.0
|
|
6
6
|
Author: Murray Vanwyk
|
|
@@ -18,6 +18,8 @@ Requires-Dist: numpy (>=1.26.3,<2.0.0)
|
|
|
18
18
|
Requires-Dist: pandas (>=2.1.4,<3.0.0)
|
|
19
19
|
Requires-Dist: pre-commit (>=3.6.2,<4.0.0)
|
|
20
20
|
Requires-Dist: pyarrow (>=14.0.2,<15.0.0)
|
|
21
|
+
Requires-Dist: scikit-learn (>=1.4.2,<2.0.0)
|
|
22
|
+
Requires-Dist: scipy (>=1.13.0,<2.0.0)
|
|
21
23
|
Requires-Dist: seaborn (>=0.13.1,<0.14.0)
|
|
22
24
|
Requires-Dist: strictyaml (>=1.7.3,<2.0.0)
|
|
23
25
|
Requires-Dist: tqdm (>=4.66.1,<5.0.0)
|
|
@@ -48,25 +50,28 @@ pip install pyretailscience
|
|
|
48
50
|
The `pyretailscience` package provides a command-line interface for generating simulated transaction data.
|
|
49
51
|
|
|
50
52
|
#### Usage
|
|
53
|
+
|
|
51
54
|
```bash
|
|
52
55
|
pyretailscience --config_file=<config_file_path> [--verbose=<True|False>] [--seed=<seed_number>] [output]
|
|
53
56
|
```
|
|
54
57
|
|
|
55
58
|
#### Options and Arguments
|
|
59
|
+
|
|
56
60
|
- `--config_file=<config_file_path>`: The path to the configuration file for the simulation. This is a required argument.
|
|
57
61
|
- `--verbose=<True|False>`: Optional. Set to `True` to see debug messages. Default is `False`.
|
|
58
62
|
- `--seed=<seed_number>`: Optional. Seed for the random number generator used in the simulation. If not provided, a random seed will be used.
|
|
59
63
|
- `[output]`: Optional. The path where the generated transactions will be saved in parquet format. If not provided, the transactions will be saved in the current directory.
|
|
60
64
|
|
|
61
65
|
#### Examples
|
|
66
|
+
|
|
62
67
|
```bash
|
|
63
68
|
# Get the default transaction config file
|
|
64
|
-
wget https://raw.githubusercontent.com/Data-Simply/pyretailscience/0.
|
|
69
|
+
wget https://raw.githubusercontent.com/Data-Simply/pyretailscience/0.3.0/data/default_data_config.yaml
|
|
65
70
|
# Generate the data file
|
|
66
71
|
pyretailscience --config_file=default_data_config.yaml --seed=123 transactions.parquet
|
|
67
72
|
```
|
|
68
|
-
This command will generate a file named `transactions.parquet` with the simulated transaction data, using the configuration file at default data configuration file, and a seed of `123` for the random number generator.
|
|
69
73
|
|
|
74
|
+
This command will generate a file named `transactions.parquet` with the simulated transaction data, using the configuration file at default data configuration file, and a seed of `123` for the random number generator.
|
|
70
75
|
|
|
71
76
|
# Contributing
|
|
72
77
|
|
|
@@ -23,25 +23,28 @@ pip install pyretailscience
|
|
|
23
23
|
The `pyretailscience` package provides a command-line interface for generating simulated transaction data.
|
|
24
24
|
|
|
25
25
|
#### Usage
|
|
26
|
+
|
|
26
27
|
```bash
|
|
27
28
|
pyretailscience --config_file=<config_file_path> [--verbose=<True|False>] [--seed=<seed_number>] [output]
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
#### Options and Arguments
|
|
32
|
+
|
|
31
33
|
- `--config_file=<config_file_path>`: The path to the configuration file for the simulation. This is a required argument.
|
|
32
34
|
- `--verbose=<True|False>`: Optional. Set to `True` to see debug messages. Default is `False`.
|
|
33
35
|
- `--seed=<seed_number>`: Optional. Seed for the random number generator used in the simulation. If not provided, a random seed will be used.
|
|
34
36
|
- `[output]`: Optional. The path where the generated transactions will be saved in parquet format. If not provided, the transactions will be saved in the current directory.
|
|
35
37
|
|
|
36
38
|
#### Examples
|
|
39
|
+
|
|
37
40
|
```bash
|
|
38
41
|
# Get the default transaction config file
|
|
39
|
-
wget https://raw.githubusercontent.com/Data-Simply/pyretailscience/0.
|
|
42
|
+
wget https://raw.githubusercontent.com/Data-Simply/pyretailscience/0.3.0/data/default_data_config.yaml
|
|
40
43
|
# Generate the data file
|
|
41
44
|
pyretailscience --config_file=default_data_config.yaml --seed=123 transactions.parquet
|
|
42
45
|
```
|
|
43
|
-
This command will generate a file named `transactions.parquet` with the simulated transaction data, using the configuration file at default data configuration file, and a seed of `123` for the random number generator.
|
|
44
46
|
|
|
47
|
+
This command will generate a file named `transactions.parquet` with the simulated transaction data, using the configuration file at default data configuration file, and a seed of `123` for the random number generator.
|
|
45
48
|
|
|
46
49
|
# Contributing
|
|
47
50
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "pyretailscience"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Retail Data Science Tools"
|
|
5
5
|
authors = ["Murray Vanwyk <2493311+mvanwyk@users.noreply.github.com>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -19,6 +19,8 @@ loguru = "^0.7.2"
|
|
|
19
19
|
tqdm = "^4.66.1"
|
|
20
20
|
great-expectations = "^0.18.8"
|
|
21
21
|
pre-commit ="^3.6.2"
|
|
22
|
+
scipy = "^1.13.0"
|
|
23
|
+
scikit-learn = "^1.4.2"
|
|
22
24
|
|
|
23
25
|
[tool.poetry.group.dev.dependencies]
|
|
24
26
|
pytest = "^8.0.0"
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import operator
|
|
2
|
+
|
|
2
3
|
import matplotlib.ticker as mtick
|
|
3
4
|
import pandas as pd
|
|
4
5
|
from matplotlib.axes import Axes, SubplotBase
|
|
5
6
|
|
|
6
7
|
from pyretailscience.data.contracts import TransactionItemLevelContract
|
|
8
|
+
from pyretailscience.style.graph_utils import GraphStyles as gs
|
|
7
9
|
from pyretailscience.style.graph_utils import human_format, standard_graph_styles
|
|
8
10
|
from pyretailscience.style.tailwind import COLORS
|
|
9
|
-
import operator
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class PurchasesPerCustomer:
|
|
@@ -33,7 +34,10 @@ class PurchasesPerCustomer:
|
|
|
33
34
|
ax: Axes | None = None,
|
|
34
35
|
draw_percentile_line: bool = False,
|
|
35
36
|
percentile_line: float = 0.5,
|
|
36
|
-
source_text: str = None,
|
|
37
|
+
source_text: str | None = None,
|
|
38
|
+
title: str | None = None,
|
|
39
|
+
xlabel: str | None = None,
|
|
40
|
+
ylabel: str | None = None,
|
|
37
41
|
**kwargs: dict[str, any],
|
|
38
42
|
) -> SubplotBase:
|
|
39
43
|
"""Plot the distribution of the number of purchases per customer.
|
|
@@ -54,6 +58,9 @@ class PurchasesPerCustomer:
|
|
|
54
58
|
if cumlative:
|
|
55
59
|
density = True
|
|
56
60
|
|
|
61
|
+
if xlabel is None:
|
|
62
|
+
xlabel = "Number of purchases"
|
|
63
|
+
|
|
57
64
|
ax = self.cust_purchases_s.hist(
|
|
58
65
|
bins=bins,
|
|
59
66
|
cumulative=cumlative,
|
|
@@ -62,21 +69,29 @@ class PurchasesPerCustomer:
|
|
|
62
69
|
color=COLORS["green"][500],
|
|
63
70
|
**kwargs,
|
|
64
71
|
)
|
|
65
|
-
|
|
72
|
+
|
|
73
|
+
ax.set_xlabel(xlabel, fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE, labelpad=10)
|
|
66
74
|
ax.xaxis.set_major_formatter(lambda x, pos: human_format(x, pos, decimals=0))
|
|
67
75
|
|
|
68
76
|
ax = standard_graph_styles(ax)
|
|
69
77
|
|
|
70
78
|
if cumlative:
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
if title is None:
|
|
80
|
+
title = "Number of Purchases Cumulative Distribution"
|
|
81
|
+
if ylabel is None:
|
|
82
|
+
ylabel = "Percentage of customers"
|
|
73
83
|
ax.yaxis.set_major_formatter(mtick.PercentFormatter(xmax=1, decimals=0))
|
|
74
84
|
|
|
75
85
|
else:
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
if title is None:
|
|
87
|
+
title = "Number of Purchases Distribution"
|
|
88
|
+
if ylabel is None:
|
|
89
|
+
ylabel = "Number of customers"
|
|
78
90
|
ax.yaxis.set_major_formatter(lambda x, pos: human_format(x, pos, decimals=0))
|
|
79
91
|
|
|
92
|
+
ax.set_title(title, fontsize=gs.DEFAULT_TITLE_FONT_SIZE, pad=15)
|
|
93
|
+
ax.set_ylabel(ylabel, fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE, labelpad=10)
|
|
94
|
+
|
|
80
95
|
if draw_percentile_line:
|
|
81
96
|
if percentile_line > 1 or percentile_line < 0:
|
|
82
97
|
raise ValueError("Percentile line must be between 0 and 1")
|
|
@@ -96,7 +111,7 @@ class PurchasesPerCustomer:
|
|
|
96
111
|
xycoords="axes fraction",
|
|
97
112
|
ha="left",
|
|
98
113
|
va="center",
|
|
99
|
-
fontsize=
|
|
114
|
+
fontsize=gs.DEFAULT_SOURCE_FONT_SIZE,
|
|
100
115
|
)
|
|
101
116
|
|
|
102
117
|
return ax
|
|
@@ -176,6 +191,9 @@ class DaysBetweenPurchases:
|
|
|
176
191
|
ax: Axes | None = None,
|
|
177
192
|
draw_percentile_line: bool = False,
|
|
178
193
|
percentile_line: float = 0.5,
|
|
194
|
+
title: str | None = None,
|
|
195
|
+
xlabel: str | None = None,
|
|
196
|
+
ylabel: str | None = None,
|
|
179
197
|
source_text: str = None,
|
|
180
198
|
**kwargs: dict[str, any],
|
|
181
199
|
) -> SubplotBase:
|
|
@@ -204,21 +222,31 @@ class DaysBetweenPurchases:
|
|
|
204
222
|
color=COLORS["green"][500],
|
|
205
223
|
**kwargs,
|
|
206
224
|
)
|
|
207
|
-
|
|
225
|
+
|
|
226
|
+
if xlabel is None:
|
|
227
|
+
xlabel = "Average Number of Days Between Purchases"
|
|
228
|
+
ax.set_xlabel(xlabel, fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE, labelpad=10)
|
|
208
229
|
ax.xaxis.set_major_formatter(lambda x, pos: human_format(x, pos, decimals=0))
|
|
209
230
|
|
|
210
231
|
ax = standard_graph_styles(ax)
|
|
211
232
|
|
|
212
233
|
if cumlative:
|
|
213
|
-
|
|
214
|
-
|
|
234
|
+
if title is None:
|
|
235
|
+
title = "Average Days Between Purchases Cumulative Distribution"
|
|
236
|
+
if ylabel is None:
|
|
237
|
+
ylabel = "Percentage of Customers"
|
|
215
238
|
ax.yaxis.set_major_formatter(mtick.PercentFormatter(xmax=1, decimals=0))
|
|
216
239
|
|
|
217
240
|
else:
|
|
218
|
-
|
|
219
|
-
|
|
241
|
+
if title is None:
|
|
242
|
+
title = "Average Days Between Purchases Distribution"
|
|
243
|
+
if ylabel is None:
|
|
244
|
+
ylabel = "Number of Customers"
|
|
220
245
|
ax.yaxis.set_major_formatter(lambda x, pos: human_format(x, pos, decimals=0))
|
|
221
246
|
|
|
247
|
+
ax.set_title(title, fontsize=gs.DEFAULT_TITLE_FONT_SIZE, pad=15)
|
|
248
|
+
ax.set_ylabel(ylabel, fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE, labelpad=10)
|
|
249
|
+
|
|
222
250
|
if draw_percentile_line:
|
|
223
251
|
if percentile_line > 1 or percentile_line < 0:
|
|
224
252
|
raise ValueError("Percentile line must be between 0 and 1")
|
|
@@ -239,7 +267,7 @@ class DaysBetweenPurchases:
|
|
|
239
267
|
xycoords="axes fraction",
|
|
240
268
|
ha="left",
|
|
241
269
|
va="center",
|
|
242
|
-
fontsize=
|
|
270
|
+
fontsize=gs.DEFAULT_SOURCE_FONT_SIZE,
|
|
243
271
|
)
|
|
244
272
|
|
|
245
273
|
return ax
|
|
@@ -306,6 +334,9 @@ class TransactionChurn:
|
|
|
306
334
|
self,
|
|
307
335
|
cumlative: bool = False,
|
|
308
336
|
ax: Axes | None = None,
|
|
337
|
+
title: str | None = None,
|
|
338
|
+
xlabel: str | None = None,
|
|
339
|
+
ylabel: str | None = None,
|
|
309
340
|
source_text: str = None,
|
|
310
341
|
**kwargs: dict[str, any],
|
|
311
342
|
) -> SubplotBase:
|
|
@@ -320,7 +351,10 @@ class TransactionChurn:
|
|
|
320
351
|
"""
|
|
321
352
|
if cumlative:
|
|
322
353
|
cumulative_churn_rate_s = self.purchase_dist_df["churned"].cumsum().div(self.n_unique_customers)
|
|
323
|
-
ax = cumulative_churn_rate_s.plot.area(
|
|
354
|
+
ax = cumulative_churn_rate_s.plot.area(
|
|
355
|
+
color=COLORS["green"][500],
|
|
356
|
+
**kwargs,
|
|
357
|
+
)
|
|
324
358
|
ax.set_xlim(self.purchase_dist_df.index.min(), self.purchase_dist_df.index.max())
|
|
325
359
|
else:
|
|
326
360
|
ax = self.purchase_dist_df["churned_pct"].plot.bar(
|
|
@@ -332,11 +366,17 @@ class TransactionChurn:
|
|
|
332
366
|
|
|
333
367
|
standard_graph_styles(ax)
|
|
334
368
|
|
|
335
|
-
|
|
369
|
+
if title is None:
|
|
370
|
+
title = "Churn Rate by Number of Purchases"
|
|
371
|
+
if xlabel is None:
|
|
372
|
+
xlabel = "Number of Purchases"
|
|
373
|
+
if ylabel is None:
|
|
374
|
+
ylabel = "% Churned"
|
|
375
|
+
|
|
336
376
|
ax.yaxis.set_major_formatter(mtick.PercentFormatter(xmax=1.0))
|
|
337
|
-
ax.set_xlabel(
|
|
338
|
-
ax.set_ylabel(
|
|
339
|
-
ax.set_title(
|
|
377
|
+
ax.set_xlabel(xlabel, fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE, labelpad=10)
|
|
378
|
+
ax.set_ylabel(ylabel, fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE, labelpad=10)
|
|
379
|
+
ax.set_title(title, fontsize=gs.DEFAULT_TITLE_FONT_SIZE, pad=15)
|
|
340
380
|
|
|
341
381
|
if source_text:
|
|
342
382
|
ax.annotate(
|
|
@@ -345,7 +385,7 @@ class TransactionChurn:
|
|
|
345
385
|
xycoords="axes fraction",
|
|
346
386
|
ha="left",
|
|
347
387
|
va="center",
|
|
348
|
-
fontsize=
|
|
388
|
+
fontsize=gs.DEFAULT_SOURCE_FONT_SIZE,
|
|
349
389
|
)
|
|
350
390
|
|
|
351
391
|
return ax
|
|
@@ -349,10 +349,12 @@ class Simulation:
|
|
|
349
349
|
"""
|
|
350
350
|
num_new_customers = self.rnd_generator.poisson(self.config["customers"]["average_new_customers_per_day"])
|
|
351
351
|
logger.debug(f"Adding {num_new_customers} new customers")
|
|
352
|
+
last_customer_id = len(self.customers)
|
|
353
|
+
last_new_customer_id = last_customer_id + num_new_customers
|
|
352
354
|
self.customers.extend(
|
|
353
355
|
[
|
|
354
356
|
self._create_customer(customer_id=new_customer_id)
|
|
355
|
-
for new_customer_id in range(
|
|
357
|
+
for new_customer_id in range(last_customer_id + 1, last_new_customer_id + 1)
|
|
356
358
|
]
|
|
357
359
|
)
|
|
358
360
|
# Simulate each customer
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
7
|
+
from scipy.cluster.hierarchy import dendrogram, linkage
|
|
8
|
+
|
|
9
|
+
import pyretailscience.style.graph_utils as gu
|
|
10
|
+
from pyretailscience.data.contracts import TransactionItemLevelContract
|
|
11
|
+
from pyretailscience.style.graph_utils import GraphStyles as gs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomerDecisionHierarchy:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
df: pd.DataFrame,
|
|
18
|
+
exclude_same_transaction_products: bool = True,
|
|
19
|
+
method: Literal["truncated_svd", "yules_q"] = "truncated_svd",
|
|
20
|
+
min_var_explained: float = 0.8,
|
|
21
|
+
random_state: int = 42,
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
Initializes the RangePlanning object.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
df (pd.DataFrame): The input dataframe containing transaction data. The dataframe must comply with the
|
|
28
|
+
TransactionItemLevelContract or the TransactionLevelContract.
|
|
29
|
+
exclude_same_transaction_products (bool, optional): Flag indicating whether to exclude products found in
|
|
30
|
+
the same transaction from a customer's distinct list of products bought. The idea is that if a
|
|
31
|
+
customer bought two products in the same transaction they can't be substitutes for that customer.
|
|
32
|
+
Thus they should be excluded from the analysis. Defaults to True.
|
|
33
|
+
method (Literal["truncated_svd", "yules_q"], optional): The method to use for calculating distances.
|
|
34
|
+
Defaults to "truncated_svd".
|
|
35
|
+
min_var_explained (float, optional): The minimum variance explained required for truncated SVD method.
|
|
36
|
+
Only applicable if method is "truncated_svd". Defaults to 0.8.
|
|
37
|
+
random_state (int, optional): Random seed for reproducibility. Defaults to 42.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If the dataframe does not comply with the TransactionItemLevelContract.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
if TransactionItemLevelContract(df).validate() is False:
|
|
44
|
+
raise ValueError("The dataframe does not comply with the TransactionItemLevelContract")
|
|
45
|
+
|
|
46
|
+
self.random_state = random_state
|
|
47
|
+
self.pairs_df = self._get_pairs(df, exclude_same_transaction_products)
|
|
48
|
+
self.distances = self._calculate_distances(method=method, min_var_explained=min_var_explained)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _get_pairs(df: pd.DataFrame, exclude_same_transaction_products: bool) -> pd.DataFrame:
|
|
52
|
+
if exclude_same_transaction_products:
|
|
53
|
+
pairs_df = df[["customer_id", "transaction_id", "product_name"]].drop_duplicates()
|
|
54
|
+
pairs_to_exclude_df = (
|
|
55
|
+
pairs_df.groupby("transaction_id")
|
|
56
|
+
.filter(lambda x: len(x) > 1)[["customer_id", "product_name"]]
|
|
57
|
+
.drop_duplicates()
|
|
58
|
+
)
|
|
59
|
+
# Drop all rows from pairs_df where customer_id and product_name are in pairs_to_exclude_df
|
|
60
|
+
pairs_df = pairs_df.merge(
|
|
61
|
+
pairs_to_exclude_df, on=["customer_id", "product_name"], how="left", indicator=True
|
|
62
|
+
)
|
|
63
|
+
pairs_df = pairs_df[pairs_df["_merge"] == "left_only"][["customer_id", "product_name"]].drop_duplicates()
|
|
64
|
+
else:
|
|
65
|
+
pairs_df = df[["customer_id", "product_name"]].drop_duplicates()
|
|
66
|
+
|
|
67
|
+
pairs_df = pairs_df.reset_index(drop=True).astype("category")
|
|
68
|
+
|
|
69
|
+
return pairs_df
|
|
70
|
+
|
|
71
|
+
def _get_truncated_svd_distances(self, min_var_explained: float = 0.8) -> np.array:
|
|
72
|
+
"""
|
|
73
|
+
Calculate the truncated SVD distances for the given pairs dataframe.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
min_var_explained (float): The minimum variance explained required.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
np.array: The normalized matrix of truncated SVD distances.
|
|
80
|
+
"""
|
|
81
|
+
from scipy.sparse import csr_matrix
|
|
82
|
+
from sklearn.decomposition import TruncatedSVD
|
|
83
|
+
|
|
84
|
+
sparse_matrix = csr_matrix(
|
|
85
|
+
(
|
|
86
|
+
[1] * len(self.pairs_df),
|
|
87
|
+
(
|
|
88
|
+
self.pairs_df["product_name"].cat.codes,
|
|
89
|
+
self.pairs_df["customer_id"].cat.codes,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
n_products = sparse_matrix.shape[0]
|
|
95
|
+
svd = TruncatedSVD(n_components=n_products, random_state=self.random_state)
|
|
96
|
+
svd.fit(sparse_matrix)
|
|
97
|
+
cuml_var = np.cumsum(svd.explained_variance_ratio_)
|
|
98
|
+
|
|
99
|
+
req_n_components = np.argmax(cuml_var >= min_var_explained) + 1
|
|
100
|
+
|
|
101
|
+
reduced_matrix = TruncatedSVD(n_components=req_n_components, random_state=self.random_state).fit_transform(
|
|
102
|
+
sparse_matrix
|
|
103
|
+
)
|
|
104
|
+
norm_matrix = reduced_matrix / np.linalg.norm(reduced_matrix, axis=1, keepdims=True)
|
|
105
|
+
|
|
106
|
+
return norm_matrix
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _calculate_yules_q(bought_product_1: np.array, bought_product_2: np.array):
|
|
110
|
+
"""
|
|
111
|
+
Calculates the Yule's Q coefficient between two binary arrays.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
bought_product_1 (np.array): Binary array representing the first bought product. Each element is 1 if the
|
|
115
|
+
customer bought the product and 0 if they didn't.
|
|
116
|
+
bought_product_2 (np.array): Binary array representing the second bought product. Each element is 1 if the
|
|
117
|
+
customer bought the product and 0 if they didn't.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
float: The Yule's Q coefficient.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If the lengths of `bought_product_1` and `bought_product_2` are not the same.
|
|
124
|
+
ValueError: If `bought_product_1` or `bought_product_2` is not a boolean array.
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
if len(bought_product_1) != len(bought_product_2):
|
|
128
|
+
raise ValueError("The bought_product_1 and bought_product_2 must be the same length")
|
|
129
|
+
if len(bought_product_1) == 0:
|
|
130
|
+
return 0.0
|
|
131
|
+
if bought_product_1.dtype != bool or bought_product_2.dtype != bool:
|
|
132
|
+
raise ValueError("The bought_product_1 and bought_product_2 must be boolean arrays")
|
|
133
|
+
|
|
134
|
+
a = np.count_nonzero(bought_product_1 & bought_product_2)
|
|
135
|
+
b = np.count_nonzero(bought_product_1 & ~bought_product_2)
|
|
136
|
+
c = np.count_nonzero(~bought_product_1 & bought_product_2)
|
|
137
|
+
d = np.count_nonzero(~bought_product_1 & ~bought_product_2)
|
|
138
|
+
|
|
139
|
+
q = (a * d - b * c) / (a * d + b * c)
|
|
140
|
+
|
|
141
|
+
return q
|
|
142
|
+
|
|
143
|
+
def _get_yules_q_distances(self) -> float:
|
|
144
|
+
"""
|
|
145
|
+
Calculate the Yules Q distances between pairs of products.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
float: The Yules Q distances between pairs of products.
|
|
149
|
+
"""
|
|
150
|
+
from scipy.sparse import csr_matrix
|
|
151
|
+
|
|
152
|
+
# Create a sparse matrix where the rows are the customers and the columns are the products
|
|
153
|
+
# The values are True if the customer bought the product and False if they didn't
|
|
154
|
+
product_matrix = csr_matrix(
|
|
155
|
+
(
|
|
156
|
+
[True] * len(self.pairs_df),
|
|
157
|
+
(
|
|
158
|
+
self.pairs_df["product_name"].cat.codes,
|
|
159
|
+
self.pairs_df["customer_id"].cat.codes,
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
dtype=bool,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Calculate the number of customers and products
|
|
166
|
+
n_products = product_matrix.shape[0]
|
|
167
|
+
|
|
168
|
+
# Create an empty matrix to store the yules q values
|
|
169
|
+
yules_q_matrix = np.zeros((n_products, n_products), dtype=float)
|
|
170
|
+
|
|
171
|
+
# Loop through each pair of products
|
|
172
|
+
for i in range(n_products):
|
|
173
|
+
arr_i = product_matrix[i].toarray()
|
|
174
|
+
for j in range(i + 1, n_products):
|
|
175
|
+
# Calculate the yules q value for the pair of products
|
|
176
|
+
arr_j = product_matrix[j].toarray()
|
|
177
|
+
yules_q_dist = 1 - self._calculate_yules_q(arr_i, arr_j)
|
|
178
|
+
|
|
179
|
+
# Store the yules q value in the matrix
|
|
180
|
+
yules_q_matrix[i, j] = yules_q_dist
|
|
181
|
+
yules_q_matrix[j, i] = yules_q_dist
|
|
182
|
+
|
|
183
|
+
# Normalize the yules q values to be between 0 and 1
|
|
184
|
+
yules_q_matrix = (yules_q_matrix + 1) / 2
|
|
185
|
+
|
|
186
|
+
return yules_q_matrix
|
|
187
|
+
|
|
188
|
+
def _calculate_distances(
|
|
189
|
+
self,
|
|
190
|
+
method: Literal["truncated_svd", "yules_q"],
|
|
191
|
+
min_var_explained: float,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Calculates distances between items using the specified method.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
method (Literal["truncated_svd", "yules_q"], optional): The method to use for calculating distances.
|
|
197
|
+
min_var_explained (float, optional): The minimum variance explained required for truncated SVD method.
|
|
198
|
+
Only applicable if method is "truncated_svd".
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValueError: If the method is not valid.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
None
|
|
205
|
+
"""
|
|
206
|
+
# Check method is valid
|
|
207
|
+
if method == "truncated_svd":
|
|
208
|
+
distances = self._get_truncated_svd_distances(min_var_explained=min_var_explained)
|
|
209
|
+
elif method == "yules_q":
|
|
210
|
+
distances = self._get_yules_q_distances()
|
|
211
|
+
else:
|
|
212
|
+
raise ValueError("Method must be 'truncated_svd' or 'yules_q'")
|
|
213
|
+
|
|
214
|
+
return distances
|
|
215
|
+
|
|
216
|
+
def plot(
|
|
217
|
+
self,
|
|
218
|
+
title: str = "Customer Decision Hierarchy",
|
|
219
|
+
x_label: str | None = None,
|
|
220
|
+
y_label: str | None = None,
|
|
221
|
+
ax: Axes | None = None,
|
|
222
|
+
figsize: tuple[int, int] | None = None,
|
|
223
|
+
source_text: str | None = None,
|
|
224
|
+
**kwargs: dict[str, any],
|
|
225
|
+
) -> SubplotBase:
|
|
226
|
+
"""
|
|
227
|
+
Plots the range planning dendrogram.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
title (str, optional): The title of the plot. Defaults to None.
|
|
231
|
+
x_label (str, optional): The label for the x-axis. Defaults to None.
|
|
232
|
+
y_label (str, optional): The label for the y-axis. Defaults to None.
|
|
233
|
+
ax (Axes, optional): The matplotlib Axes object to plot on. Defaults to None.
|
|
234
|
+
figsize (tuple[int, int], optional): The figure size. Defaults to None.
|
|
235
|
+
source_text (str, optional): The source text to annotate on the plot. Defaults to None.
|
|
236
|
+
**kwargs (dict[str, any]): Additional keyword arguments to pass to the dendrogram function.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
SubplotBase: The matplotlib SubplotBase object.
|
|
240
|
+
"""
|
|
241
|
+
linkage_matrix = linkage(self.distances, method="ward")
|
|
242
|
+
labels = self.pairs_df["product_name"].cat.categories
|
|
243
|
+
|
|
244
|
+
if ax is None:
|
|
245
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
246
|
+
|
|
247
|
+
orientation = kwargs.get("orientation", "top")
|
|
248
|
+
if orientation in ["top", "bottom"]:
|
|
249
|
+
default_x_label = "Products"
|
|
250
|
+
default_y_label = "Distance"
|
|
251
|
+
else:
|
|
252
|
+
default_x_label = "Distance"
|
|
253
|
+
default_y_label = "Products"
|
|
254
|
+
|
|
255
|
+
ax.set_title(
|
|
256
|
+
title,
|
|
257
|
+
fontsize=gs.DEFAULT_TITLE_FONT_SIZE,
|
|
258
|
+
pad=15,
|
|
259
|
+
)
|
|
260
|
+
ax.set_xlabel(
|
|
261
|
+
gu.not_none(y_label, default_x_label),
|
|
262
|
+
fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE,
|
|
263
|
+
labelpad=10,
|
|
264
|
+
)
|
|
265
|
+
ax.set_ylabel(
|
|
266
|
+
gu.not_none(x_label, default_y_label),
|
|
267
|
+
fontsize=gs.DEFAULT_AXIS_LABEL_FONT_SIZE,
|
|
268
|
+
labelpad=10,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Set the y label to be on the right side of the plot
|
|
272
|
+
if orientation == "left":
|
|
273
|
+
ax.yaxis.tick_right()
|
|
274
|
+
ax.yaxis.set_label_position("right")
|
|
275
|
+
if orientation == "bottom":
|
|
276
|
+
ax.xaxis.tick_top()
|
|
277
|
+
ax.xaxis.set_label_position("top")
|
|
278
|
+
|
|
279
|
+
dendrogram(linkage_matrix, labels=labels, ax=ax, **kwargs)
|
|
280
|
+
|
|
281
|
+
if source_text is not None:
|
|
282
|
+
plt.draw()
|
|
283
|
+
|
|
284
|
+
y_axis_offset = -0.1
|
|
285
|
+
if orientation != "bottom":
|
|
286
|
+
bbox_height = ax.get_xaxis().get_tightbbox().height
|
|
287
|
+
axis_height = bbox_height / ax.figure.dpi / ax.figure.get_figheight()
|
|
288
|
+
y_axis_offset = min(-axis_height * 1.5, -0.1)
|
|
289
|
+
|
|
290
|
+
x_axis_offset = -0.1
|
|
291
|
+
if orientation == "right":
|
|
292
|
+
bbox_width = ax.get_yaxis().get_tightbbox().width
|
|
293
|
+
axis_width = bbox_width / ax.figure.dpi / ax.figure.get_figwidth()
|
|
294
|
+
x_axis_offset = min(-axis_width * 1.25, -0.1)
|
|
295
|
+
|
|
296
|
+
ax.annotate(
|
|
297
|
+
source_text,
|
|
298
|
+
xy=(x_axis_offset, y_axis_offset),
|
|
299
|
+
xycoords="axes fraction",
|
|
300
|
+
ha="left",
|
|
301
|
+
va="center",
|
|
302
|
+
fontsize=gs.DEFAULT_SOURCE_FONT_SIZE,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if orientation in ["top", "bottom"]:
|
|
306
|
+
ax.xaxis.set_tick_params(labelsize=gs.DEFAULT_TICK_LABEL_FONT_SIZE)
|
|
307
|
+
else:
|
|
308
|
+
ax.yaxis.set_tick_params(labelsize=gs.DEFAULT_TICK_LABEL_FONT_SIZE)
|
|
309
|
+
|
|
310
|
+
return ax
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
5
|
+
|
|
6
|
+
import pyretailscience.style.graph_utils as gu
|
|
7
|
+
from pyretailscience.data.contracts import TransactionItemLevelContract, TransactionLevelContract
|
|
8
|
+
from pyretailscience.style.tailwind import COLORS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HMLSegmentation:
|
|
12
|
+
def __init__(self, df: pd.DataFrame, value_col: str = "total_price") -> None:
|
|
13
|
+
"""
|
|
14
|
+
Segments customers into Heavy, Medium, Light and Zero spenders based on the total spend.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
df (pd.DataFrame): A dataframe with the transaction data. The dataframe must comply with the
|
|
18
|
+
TransactionItemLevelContract or the TransactionLevelContract.
|
|
19
|
+
value_col (str, optional): The column to use for the segmentation. Defaults to "total_price".
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: If the dataframe does not comply with the TransactionItemLevelContract or
|
|
23
|
+
TransactionLevelContract.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
if TransactionItemLevelContract(df).validate() is False and TransactionLevelContract(df).validate() is False:
|
|
27
|
+
raise ValueError("The dataframe does not comply with the TransactionItemLevelContract")
|
|
28
|
+
|
|
29
|
+
# Group by customer_id and calculate total_spend
|
|
30
|
+
grouped_df = df.groupby("customer_id")[value_col].sum().to_frame(value_col)
|
|
31
|
+
|
|
32
|
+
# Separate customers with zero spend
|
|
33
|
+
zero_idx = grouped_df[value_col] == 0
|
|
34
|
+
zero_cust_df = grouped_df[zero_idx]
|
|
35
|
+
zero_cust_df["segment_name"] = "Zero"
|
|
36
|
+
|
|
37
|
+
hml_df = grouped_df[~zero_idx]
|
|
38
|
+
|
|
39
|
+
# Create a new column 'segment' based on the total_spend
|
|
40
|
+
hml_df["segment_name"] = pd.qcut(
|
|
41
|
+
hml_df[value_col],
|
|
42
|
+
q=[0, 0.500, 0.800, 1],
|
|
43
|
+
labels=["Light", "Medium", "Heavy"],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
hml_df = pd.concat([hml_df, zero_cust_df])
|
|
47
|
+
|
|
48
|
+
segment_code_map = {"Light": "L", "Medium": "M", "Heavy": "H", "Zero": "Z"}
|
|
49
|
+
|
|
50
|
+
hml_df["segment_id"] = hml_df["segment_name"].map(segment_code_map)
|
|
51
|
+
|
|
52
|
+
self.df = grouped_df
|
|
53
|
+
|
|
54
|
+
def add_segment(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
55
|
+
"""
|
|
56
|
+
Adds the segment to the dataframe based on the customer_id column.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
df (pd.DataFrame): The dataframe to add the segment to. The dataframe must have a customer_id column.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
pd.DataFrame: The dataframe with the segment added.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If the number of rows before and after the merge do not match.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# TODO: Add a contract that ensures there's a customer ID column or matches one or more of a set of contracts
|
|
69
|
+
# efficently - Eg checks all the quick validations and then tries the extended validations
|
|
70
|
+
|
|
71
|
+
rows_before = len(df)
|
|
72
|
+
df = df.merge(self.df[["segment_name", "segment_id"]], how="left", left_on="customer_id", right_index=True)
|
|
73
|
+
rows_after = len(df)
|
|
74
|
+
if rows_before != rows_after:
|
|
75
|
+
raise ValueError("The number of rows before and after the merge do not match. This should not happen.")
|
|
76
|
+
|
|
77
|
+
return df
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SegTransactionStats:
|
|
81
|
+
def __init__(self, df: pd.DataFrame, segment_col: str = "segment_id") -> None:
|
|
82
|
+
"""
|
|
83
|
+
Calculates transaction statistics by segment.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
df (pd.DataFrame): A dataframe with the transaction data. The dataframe must comply with the
|
|
87
|
+
TransactionItemLevelContract or the TransactionLevelContract.
|
|
88
|
+
segment_col (str, optional): The column to use for the segmentation. Defaults to "segment_id".
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
NotImplementedError: If the dataframe does not comply with the TransactionItemLevelContract or
|
|
92
|
+
TransactionLevelContract.
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
self.segment_col = segment_col
|
|
96
|
+
if TransactionItemLevelContract(df).validate() is True:
|
|
97
|
+
stats_df = df.groupby(segment_col).agg(
|
|
98
|
+
revenue=("total_price", "sum"),
|
|
99
|
+
transactions=("transaction_id", "nunique"),
|
|
100
|
+
customers=("customer_id", "nunique"),
|
|
101
|
+
total_quantity=("quantity", "sum"),
|
|
102
|
+
)
|
|
103
|
+
stats_df["price_per_unit"] = stats_df["revenue"] / stats_df["total_quantity"]
|
|
104
|
+
stats_df["quantity_per_transaction"] = stats_df["total_quantity"] / stats_df["transactions"]
|
|
105
|
+
elif TransactionLevelContract(df).validate() is True:
|
|
106
|
+
stats_df = df.groupby(segment_col).agg(
|
|
107
|
+
revenue=("total_price", "sum"),
|
|
108
|
+
transactions=("transaction_id", "nunique"),
|
|
109
|
+
customers=("customer_id", "nunique"),
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
raise NotImplementedError(
|
|
113
|
+
"The dataframe does not comply with the TransactionItemLevelContract or TransactionLevelContract. "
|
|
114
|
+
"These are the only two contracts supported at this time."
|
|
115
|
+
)
|
|
116
|
+
total_num_customers = df["customer_id"].nunique()
|
|
117
|
+
stats_df["spend_per_cust"] = stats_df["revenue"] / stats_df["customers"]
|
|
118
|
+
stats_df["spend_per_transaction"] = stats_df["revenue"] / stats_df["transactions"]
|
|
119
|
+
stats_df["transactions_per_customer"] = stats_df["transactions"] / stats_df["customers"]
|
|
120
|
+
stats_df["customers_pct"] = stats_df["customers"] / total_num_customers
|
|
121
|
+
|
|
122
|
+
self.df = stats_df
|
|
123
|
+
|
|
124
|
+
def plot(
|
|
125
|
+
self,
|
|
126
|
+
value_col: str,
|
|
127
|
+
title: str | None = None,
|
|
128
|
+
x_label: str | None = None,
|
|
129
|
+
y_label: str | None = None,
|
|
130
|
+
ax: Axes | None = None,
|
|
131
|
+
orientation: Literal["vertical", "horizontal"] = "vertical",
|
|
132
|
+
sort_order: Literal["ascending", "descending", None] = None,
|
|
133
|
+
source_text: str = None,
|
|
134
|
+
**kwargs: dict[str, any],
|
|
135
|
+
) -> SubplotBase:
|
|
136
|
+
"""
|
|
137
|
+
Plots the value_col by segment.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
value_col (str): The column to plot.
|
|
141
|
+
title (str, optional): The title of the plot. Defaults to None.
|
|
142
|
+
x_label (str, optional): The x-axis label. Defaults to None. When None the x-axis label is blank when the
|
|
143
|
+
orientation is horizontal. When the orientation is vertical it is set to the `value_col` in title case.
|
|
144
|
+
y_label (str, optional): The y-axis label. Defaults to None. When None the y-axis label is set to the
|
|
145
|
+
`value_col` in title case when the orientation is horizontal. Then the orientation is vertical it is
|
|
146
|
+
set to blank
|
|
147
|
+
ax (Axes, optional): The matplotlib axes object to plot on. Defaults to None.
|
|
148
|
+
orientation (Literal["vertical", "horizontal"], optional): The orientation of the plot. Defaults to
|
|
149
|
+
"vertical".
|
|
150
|
+
sort_order (Literal["ascending", "descending", None], optional): The sort order of the segments.
|
|
151
|
+
Defaults to None. If None, the segments are plotted in the order they appear in the dataframe.
|
|
152
|
+
source_text (str, optional): The source text to add to the plot. Defaults to None.
|
|
153
|
+
**kwargs: Additional keyword arguments to pass to the Pandas plot function.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
SubplotBase: The matplotlib axes object.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If the sort_order is not "ascending", "descending" or None.
|
|
160
|
+
ValueError: If the orientation is not "vertical" or "horizontal".
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
if sort_order not in ["ascending", "descending", None]:
|
|
164
|
+
raise ValueError("sort_order must be either 'ascending' or 'descending' or None")
|
|
165
|
+
if orientation not in ["vertical", "horizontal"]:
|
|
166
|
+
raise ValueError("orientation must be either 'vertical' or 'horizontal'")
|
|
167
|
+
|
|
168
|
+
default_title = f"{value_col.title()} by Segment"
|
|
169
|
+
kind = "bar"
|
|
170
|
+
if orientation == "horizontal":
|
|
171
|
+
kind = "barh"
|
|
172
|
+
|
|
173
|
+
val_s = self.df[value_col]
|
|
174
|
+
if sort_order is not None:
|
|
175
|
+
ascending = sort_order == "ascending"
|
|
176
|
+
val_s = val_s.sort_values(ascending=ascending)
|
|
177
|
+
|
|
178
|
+
ax = val_s.plot(
|
|
179
|
+
kind=kind,
|
|
180
|
+
color=COLORS["green"][500],
|
|
181
|
+
legend=False,
|
|
182
|
+
ax=ax,
|
|
183
|
+
**kwargs,
|
|
184
|
+
)
|
|
185
|
+
ax = gu.standard_graph_styles(ax)
|
|
186
|
+
|
|
187
|
+
if orientation == "vertical":
|
|
188
|
+
plot_y_label = gu.not_none(y_label, value_col.title())
|
|
189
|
+
plot_x_label = gu.not_none(x_label, "")
|
|
190
|
+
decimals = gu.get_decimals(ax.get_ylim(), ax.get_yticks())
|
|
191
|
+
ax.yaxis.set_major_formatter(lambda x, pos: gu.human_format(x, pos, decimals=decimals))
|
|
192
|
+
else:
|
|
193
|
+
plot_y_label = gu.not_none(y_label, "")
|
|
194
|
+
plot_x_label = gu.not_none(x_label, value_col.title())
|
|
195
|
+
decimals = gu.get_decimals(ax.get_xlim(), ax.get_xticks())
|
|
196
|
+
ax.xaxis.set_major_formatter(lambda x, pos: gu.human_format(x, pos, decimals=decimals))
|
|
197
|
+
|
|
198
|
+
ax.set_title(gu.not_none(title, default_title))
|
|
199
|
+
ax.set_ylabel(plot_y_label)
|
|
200
|
+
ax.set_xlabel(plot_x_label)
|
|
201
|
+
|
|
202
|
+
if source_text is not None:
|
|
203
|
+
ax.annotate(
|
|
204
|
+
source_text,
|
|
205
|
+
xy=(-0.1, -0.2),
|
|
206
|
+
xycoords="axes fraction",
|
|
207
|
+
ha="left",
|
|
208
|
+
va="center",
|
|
209
|
+
fontsize=10,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return ax
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
4
|
+
from pandas.tseries.offsets import BaseOffset
|
|
5
|
+
|
|
6
|
+
import pyretailscience.style.graph_utils as gu
|
|
7
|
+
from pyretailscience.style.tailwind import COLORS, get_linear_cmap
|
|
8
|
+
|
|
9
|
+
# TODO: Consider simplifying this by reducing the color range in the get_linear_cmap function.
|
|
10
|
+
COLORMAP_MIN = 0.25
|
|
11
|
+
COLORMAP_MAX = 0.75
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def time_plot(
|
|
15
|
+
df: pd.DataFrame,
|
|
16
|
+
value_col: str,
|
|
17
|
+
period: str | BaseOffset = "D",
|
|
18
|
+
agg_func: str = "sum",
|
|
19
|
+
group_col: str | None = None,
|
|
20
|
+
title: str | None = None,
|
|
21
|
+
x_label: str | None = None,
|
|
22
|
+
y_label: str | None = None,
|
|
23
|
+
ax: Axes | None = None,
|
|
24
|
+
source_text: str = None,
|
|
25
|
+
**kwargs: dict[str, any],
|
|
26
|
+
) -> SubplotBase:
|
|
27
|
+
"""
|
|
28
|
+
Plots the value_col over time.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
df (pd.DataFrame): The dataframe to plot.
|
|
32
|
+
value_col (str): The column to plot.
|
|
33
|
+
period (str | BaseOffset): The period to group the data by.
|
|
34
|
+
agg_func (str, optional): The aggregation function to apply to the value_col. Defaults to "sum".
|
|
35
|
+
group_col (str, optional): The column to group the data by. Defaults to None.
|
|
36
|
+
title (str, optional): The title of the plot. Defaults to None. When None the title is set to
|
|
37
|
+
`f"{value_col.title()} by {group_col.title()}"`
|
|
38
|
+
x_label (str, optional): The x-axis label. Defaults to None. When None the x-axis label is set to blank
|
|
39
|
+
y_label (str, optional): The y-axis label. Defaults to None. When None the y-axis label is set to the title
|
|
40
|
+
case of `value_col`
|
|
41
|
+
ax (Axes, optional): The matplotlib axes object to plot on. Defaults to None.
|
|
42
|
+
source_text (str, optional): The source text to add to the plot. Defaults to None.
|
|
43
|
+
**kwargs: Additional keyword arguments to pass to the Pandas plot function.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
SubplotBase: The matplotlib axes object.
|
|
47
|
+
"""
|
|
48
|
+
df["transaction_period"] = df["transaction_datetime"].dt.to_period(period)
|
|
49
|
+
|
|
50
|
+
if group_col is None:
|
|
51
|
+
colors = COLORS["green"][500]
|
|
52
|
+
df = df.groupby("transaction_period")[value_col].agg(agg_func)
|
|
53
|
+
default_title = "Total Sales"
|
|
54
|
+
show_legend = False
|
|
55
|
+
else:
|
|
56
|
+
colors = get_linear_cmap("green")(np.linspace(COLORMAP_MIN, COLORMAP_MAX, df[group_col].nunique()))
|
|
57
|
+
df = (
|
|
58
|
+
df.groupby([group_col, "transaction_period"])[value_col]
|
|
59
|
+
.agg(agg_func)
|
|
60
|
+
.reset_index()
|
|
61
|
+
.pivot(index="transaction_period", columns=group_col, values=value_col)
|
|
62
|
+
)
|
|
63
|
+
default_title = f"{value_col.title()} by {group_col.title()}"
|
|
64
|
+
show_legend = True
|
|
65
|
+
|
|
66
|
+
ax = df.plot(
|
|
67
|
+
linewidth=3,
|
|
68
|
+
color=colors,
|
|
69
|
+
legend=show_legend,
|
|
70
|
+
ax=ax,
|
|
71
|
+
**kwargs,
|
|
72
|
+
)
|
|
73
|
+
ax = gu.standard_graph_styles(ax)
|
|
74
|
+
ax.set_ylabel(gu.not_none(y_label, value_col.title()))
|
|
75
|
+
ax.set_title(gu.not_none(title, default_title))
|
|
76
|
+
ax.set_xlabel(gu.not_none(x_label, ""))
|
|
77
|
+
|
|
78
|
+
decimals = gu.get_decimals(ax.get_ylim(), ax.get_yticks())
|
|
79
|
+
ax.yaxis.set_major_formatter(lambda x, pos: gu.human_format(x, pos, decimals=decimals))
|
|
80
|
+
|
|
81
|
+
if show_legend:
|
|
82
|
+
legend = ax.legend(title="Segment", frameon=True)
|
|
83
|
+
legend.get_frame().set_facecolor("white")
|
|
84
|
+
legend.get_frame().set_edgecolor("white")
|
|
85
|
+
|
|
86
|
+
if source_text is not None:
|
|
87
|
+
ax.annotate(
|
|
88
|
+
source_text,
|
|
89
|
+
xy=(-0.1, -0.2),
|
|
90
|
+
xycoords="axes fraction",
|
|
91
|
+
ha="left",
|
|
92
|
+
va="center",
|
|
93
|
+
fontsize=10,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return ax
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from matplotlib.axes import Axes
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GraphStyles:
|
|
5
|
+
"""A class to hold the styles for a graph."""
|
|
6
|
+
|
|
7
|
+
DEFAULT_TITLE_FONT_SIZE = 16
|
|
8
|
+
DEFAULT_SOURCE_FONT_SIZE = 10
|
|
9
|
+
DEFAULT_AXIS_LABEL_FONT_SIZE = 12
|
|
10
|
+
DEFAULT_TICK_LABEL_FONT_SIZE = 10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def human_format(num, pos=None, decimals=0, prefix="") -> str:
|
|
14
|
+
"""Format a number in a human readable format for Matplotlib.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
num (float): The number to format.
|
|
18
|
+
pos (int, optional): The position. Defaults to None. Only used for Matplotlib compatibility.
|
|
19
|
+
decimals (int, optional): The number of decimals. Defaults to 0.
|
|
20
|
+
prefix (str, optional): The prefix of the returned string, eg '$'. Defaults to "".
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
str: The formatted number.
|
|
24
|
+
"""
|
|
25
|
+
magnitude = 0
|
|
26
|
+
while abs(num) >= 1000:
|
|
27
|
+
magnitude += 1
|
|
28
|
+
num /= 1000.0
|
|
29
|
+
|
|
30
|
+
# Add more suffixes if you need them
|
|
31
|
+
return f"{prefix}%.{decimals}f%s" % (num, ["", "K", "M", "G", "T", "P"][magnitude])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def standard_graph_styles(ax: Axes) -> Axes:
|
|
35
|
+
"""Apply standard styles to a Matplotlib graph.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
ax (Axes): The graph to apply the styles to.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Axes: The graph with the styles applied.
|
|
42
|
+
"""
|
|
43
|
+
ax.spines[["top", "right"]].set_visible(False)
|
|
44
|
+
ax.grid(which="major", axis="x", color="#DAD8D7", alpha=0.5, zorder=1)
|
|
45
|
+
ax.grid(which="major", axis="y", color="#DAD8D7", alpha=0.5, zorder=1)
|
|
46
|
+
return ax
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def not_none(value1, value2):
|
|
50
|
+
"""
|
|
51
|
+
Helper funciont that returns the first value that is not None.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
value1: The first value.
|
|
55
|
+
value2: The second value.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The first value that is not None.
|
|
59
|
+
"""
|
|
60
|
+
if value1 is None:
|
|
61
|
+
return value2
|
|
62
|
+
return value1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_decimals(ylim: tuple[float, float], tick_values: list[float], max_decimals: int = 100) -> int:
|
|
66
|
+
"""
|
|
67
|
+
Helper function for the `human_format` function that determines the number of decimals to use for the y-axis.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
ylim: The y-axis limits.
|
|
71
|
+
tick_values: The y-axis tick values.
|
|
72
|
+
max_decimals: The maximum number of decimals to use. Defaults to 100.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
int: The number of decimals to use.
|
|
76
|
+
"""
|
|
77
|
+
decimals = 0
|
|
78
|
+
while True:
|
|
79
|
+
tick_labels = [human_format(t, 0, decimals=decimals) for t in tick_values if t >= ylim[0] and t <= ylim[1]]
|
|
80
|
+
if len(tick_labels) == len(set(tick_labels)) or decimals == max_decimals:
|
|
81
|
+
break
|
|
82
|
+
decimals += 1
|
|
83
|
+
return decimals
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
from matplotlib.axes import Axes
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def human_format(num, pos=None, decimals=0, prefix="") -> str:
|
|
5
|
-
"""Format a number in a human readable format for Matplotlib.
|
|
6
|
-
|
|
7
|
-
Args:
|
|
8
|
-
num (float): The number to format.
|
|
9
|
-
pos (int, optional): The position. Defaults to None. Only used for Matplotlib compatibility.
|
|
10
|
-
decimals (int, optional): The number of decimals. Defaults to 0.
|
|
11
|
-
prefix (str, optional): The prefix of the returned string, eg '$'. Defaults to "".
|
|
12
|
-
|
|
13
|
-
Returns:
|
|
14
|
-
str: The formatted number.
|
|
15
|
-
"""
|
|
16
|
-
magnitude = 0
|
|
17
|
-
while abs(num) >= 1000:
|
|
18
|
-
magnitude += 1
|
|
19
|
-
num /= 1000.0
|
|
20
|
-
|
|
21
|
-
# Add more suffixes if you need them
|
|
22
|
-
return f"{prefix}%.{decimals}f%s" % (num, ["", "K", "M", "G", "T", "P"][magnitude])
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def standard_graph_styles(ax: Axes) -> Axes:
|
|
26
|
-
"""Apply standard styles to a Matplotlib graph.
|
|
27
|
-
|
|
28
|
-
Args:
|
|
29
|
-
ax (Axes): The graph to apply the styles to.
|
|
30
|
-
|
|
31
|
-
Returns:
|
|
32
|
-
Axes: The graph with the styles applied.
|
|
33
|
-
"""
|
|
34
|
-
ax.spines[["top", "right"]].set_visible(False)
|
|
35
|
-
ax.grid(which="major", axis="x", color="#DAD8D7", alpha=0.5, zorder=1)
|
|
36
|
-
ax.grid(which="major", axis="y", color="#DAD8D7", alpha=0.5, zorder=1)
|
|
37
|
-
return ax
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|