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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyretailscience
3
- Version: 0.2.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.1.1/data/default_data_config.yaml
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.1.1/data/default_data_config.yaml
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.2.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 matplotlib.pyplot as plt
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
- ax.set_xlabel("Number of purchases")
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
- plt.title("Number of Purchases Cumulative Distribution")
72
- plt.ylabel("Percentage of customers")
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
- plt.title("Number of Purchases Distribution")
77
- plt.ylabel("Number of customers")
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=10,
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
- plt.xlabel("Average Number of Days Between Purchases")
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
- plt.title("Average Days Between Purchases Cumulative Distribution")
214
- plt.ylabel("Percentage of Customers")
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
- plt.title("Average Days Between Purchases Distribution")
219
- plt.ylabel("Number of Customers")
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=10,
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(color=COLORS["green"][500])
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
- # Format y axis as a percent
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("Number of Purchases")
338
- ax.set_ylabel("% Churned")
339
- ax.set_title("Churn Rate by Number of Purchases")
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=10,
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(len(self.customers) + 1, num_new_customers + 1)
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