priorityx 0.1.1__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.
priorityx/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """priorityx: Entity prioritization and escalation detection using GLMM statistical models."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # API will be imported
6
+
7
+ __all__ = ["__version__"]
File without changes
@@ -0,0 +1,224 @@
1
+ """Data quality filters for entity analysis."""
2
+
3
+ from datetime import timedelta
4
+
5
+ import pandas as pd
6
+ import polars as pl
7
+
8
+
9
+ def filter_sparse_entities(
10
+ df: pl.DataFrame,
11
+ entity_col: str,
12
+ timestamp_col: str,
13
+ min_total_count: int = 20,
14
+ min_observations: int = 3,
15
+ temporal_granularity: str = "yearly",
16
+ ) -> pl.DataFrame:
17
+ """
18
+ Filter entities with insufficient data.
19
+
20
+ Removes entities that don't meet minimum thresholds for:
21
+ - Total count across all time periods
22
+ - Number of time period observations
23
+
24
+ Args:
25
+ df: Input polars DataFrame
26
+ entity_col: Column name for entity identifier
27
+ timestamp_col: Column name for timestamp
28
+ min_total_count: Minimum total count (default: 20)
29
+ min_observations: Minimum time periods (default: 3)
30
+ Auto-adjusts for temporal_granularity
31
+ temporal_granularity: 'yearly', 'quarterly', or 'semiannual'
32
+
33
+ Returns:
34
+ Filtered DataFrame with only entities meeting thresholds
35
+
36
+ Examples:
37
+ >>> df_filtered = filter_sparse_entities(
38
+ ... df, entity_col="service", timestamp_col="date",
39
+ ... min_total_count=50, min_observations=8
40
+ ... )
41
+ """
42
+ n_before = df.select(entity_col).n_unique()
43
+
44
+ # filter by total count
45
+ if min_total_count > 0:
46
+ total_counts = df.group_by(entity_col).agg(pl.len().alias("total_count"))
47
+ valid_entities = total_counts.filter(
48
+ pl.col("total_count") >= min_total_count
49
+ )[entity_col]
50
+ df = df.filter(pl.col(entity_col).is_in(valid_entities))
51
+
52
+ # filter by observations (time periods)
53
+ if min_observations > 0:
54
+ # auto-adjust for granularity
55
+ min_obs = min_observations
56
+ if min_obs == 3 and temporal_granularity == "quarterly":
57
+ min_obs = 8 # 2 years
58
+ elif min_obs == 3 and temporal_granularity == "semiannual":
59
+ min_obs = 4 # 2 years
60
+
61
+ # count unique periods per entity
62
+ if temporal_granularity == "quarterly":
63
+ df_temp = df.with_columns(
64
+ [
65
+ pl.col(timestamp_col).dt.year().alias("year"),
66
+ pl.col(timestamp_col).dt.quarter().alias("quarter"),
67
+ ]
68
+ )
69
+ period_counts = df_temp.group_by(entity_col).agg(
70
+ pl.struct(["year", "quarter"]).n_unique().alias("n_periods")
71
+ )
72
+ elif temporal_granularity == "semiannual":
73
+ df_temp = df.with_columns(
74
+ [
75
+ pl.col(timestamp_col).dt.year().alias("year"),
76
+ pl.col(timestamp_col).dt.quarter().alias("quarter"),
77
+ ]
78
+ ).with_columns(
79
+ pl.when(pl.col("quarter") <= 2)
80
+ .then(pl.lit(1))
81
+ .otherwise(pl.lit(2))
82
+ .alias("semester")
83
+ )
84
+ period_counts = df_temp.group_by(entity_col).agg(
85
+ pl.struct(["year", "semester"]).n_unique().alias("n_periods")
86
+ )
87
+ else: # yearly
88
+ df_temp = df.with_columns(pl.col(timestamp_col).dt.year().alias("year"))
89
+ period_counts = df_temp.group_by(entity_col).agg(
90
+ pl.col("year").n_unique().alias("n_periods")
91
+ )
92
+
93
+ valid_entities = period_counts.filter(
94
+ pl.col("n_periods") >= min_obs
95
+ )[entity_col]
96
+ df = df.filter(pl.col(entity_col).is_in(valid_entities))
97
+
98
+ n_after = df.select(entity_col).n_unique()
99
+ n_filtered = n_before - n_after
100
+
101
+ if n_filtered > 0:
102
+ print(f"Filtered {n_filtered} sparse entities")
103
+
104
+ return df
105
+
106
+
107
+ def filter_stale_entities(
108
+ df: pl.DataFrame,
109
+ entity_col: str,
110
+ timestamp_col: str,
111
+ decline_window_quarters: int = 6,
112
+ ) -> pl.DataFrame:
113
+ """
114
+ Filter entities inactive for more than N quarters.
115
+
116
+ Removes entities whose last observation is too far in the past
117
+ relative to the dataset's most recent date. Prevents long-inactive
118
+ entities from contaminating the model baseline.
119
+
120
+ Args:
121
+ df: Input polars DataFrame
122
+ entity_col: Column name for entity identifier
123
+ timestamp_col: Column name for timestamp (Date type)
124
+ decline_window_quarters: Maximum quarters since last observation (default: 6)
125
+
126
+ Returns:
127
+ Filtered DataFrame excluding stale entities
128
+
129
+ Examples:
130
+ >>> df_active = filter_stale_entities(
131
+ ... df, entity_col="service", timestamp_col="date",
132
+ ... decline_window_quarters=6
133
+ ... )
134
+ """
135
+ if decline_window_quarters <= 0:
136
+ return df
137
+
138
+ # find last observation per entity
139
+ last_observation = df.group_by(entity_col).agg(
140
+ pl.col(timestamp_col).max().alias("last_date")
141
+ )
142
+
143
+ # get dataset max date
144
+ dataset_max_datetime = df.select(pl.col(timestamp_col).max()).item()
145
+ dataset_max_date = (
146
+ dataset_max_datetime.date()
147
+ if hasattr(dataset_max_datetime, "date")
148
+ else dataset_max_datetime
149
+ )
150
+
151
+ # calculate cutoff date
152
+ decline_cutoff = dataset_max_date - timedelta(
153
+ days=decline_window_quarters * 91 # ~91 days per quarter
154
+ )
155
+
156
+ n_before = df.select(entity_col).n_unique()
157
+
158
+ # identify stale entities
159
+ stale_entities = last_observation.filter(
160
+ pl.col("last_date").cast(pl.Date) < pl.lit(decline_cutoff).cast(pl.Date)
161
+ )[entity_col]
162
+
163
+ # filter them out
164
+ df = df.filter(~pl.col(entity_col).is_in(stale_entities))
165
+
166
+ n_after = df.select(entity_col).n_unique()
167
+ n_filtered = n_before - n_after
168
+
169
+ if n_filtered > 0:
170
+ print(
171
+ f" Filtered {n_filtered} stale entities (inactive >{decline_window_quarters}Q)"
172
+ )
173
+
174
+ return df
175
+
176
+
177
+ def filter_sparse_quarters(
178
+ movement_df: pd.DataFrame,
179
+ quarter_col: str = "quarter",
180
+ entity_col: str = "entity",
181
+ min_entities_per_quarter: int = 3,
182
+ ) -> pd.DataFrame:
183
+ """
184
+ Remove quarters with too few entity observations.
185
+
186
+ Prevents unstable GLMM estimates from sparse time periods.
187
+ Useful for cumulative movement tracking where early quarters
188
+ may have insufficient entities.
189
+
190
+ Args:
191
+ movement_df: Pandas DataFrame with movement tracking data
192
+ quarter_col: Column name for quarter identifier (default: "quarter")
193
+ entity_col: Column name for entity identifier (default: "entity")
194
+ min_entities_per_quarter: Minimum entities required (default: 3)
195
+
196
+ Returns:
197
+ Filtered DataFrame with only quarters meeting threshold
198
+
199
+ Examples:
200
+ >>> movement_clean = filter_sparse_quarters(
201
+ ... movement_df, min_entities_per_quarter=5
202
+ ... )
203
+ """
204
+ # count unique entities per quarter
205
+ quarter_counts = movement_df.groupby(quarter_col)[entity_col].nunique()
206
+
207
+ # identify valid quarters
208
+ valid_quarters = quarter_counts[
209
+ quarter_counts >= min_entities_per_quarter
210
+ ].index
211
+
212
+ # filter
213
+ filtered_df = movement_df[movement_df[quarter_col].isin(valid_quarters)]
214
+
215
+ n_dropped = len(movement_df) - len(filtered_df)
216
+ n_quarters_dropped = len(quarter_counts) - len(valid_quarters)
217
+
218
+ if n_dropped > 0:
219
+ print(
220
+ f" Filtered {n_quarters_dropped} sparse quarters "
221
+ f"({n_dropped} observations, <{min_entities_per_quarter} entities/quarter)"
222
+ )
223
+
224
+ return filtered_df
priorityx/core/glmm.py ADDED
@@ -0,0 +1,377 @@
1
+ """GLMM estimation for entity prioritization using Poisson mixed models."""
2
+
3
+ from typing import Dict, Literal, Optional, Tuple
4
+
5
+ import pandas as pd
6
+ import polars as pl
7
+ from statsmodels.genmod.bayes_mixed_glm import PoissonBayesMixedGLM
8
+
9
+ # default priors (validated on regulatory data)
10
+ DEFAULT_VCP_P = 3.5 # prior scale for random effects (higher = less shrinkage)
11
+ DEFAULT_FE_P = 3.0 # prior scale for fixed effects
12
+
13
+
14
+ def _extract_random_effects(
15
+ glmm_model, glmm_result
16
+ ) -> tuple[dict[str, float], dict[str, float]]:
17
+ """Extract random intercepts and slopes from statsmodels result."""
18
+ intercepts: dict[str, float] = {}
19
+ slopes: dict[str, float] = {}
20
+ for name, value in zip(glmm_model.vc_names, glmm_result.vc_mean):
21
+ entity = name.split("[", 1)[1].split("]", 1)[0]
22
+ val = float(value)
23
+ if ":time" in name:
24
+ slopes[entity] = val
25
+ else:
26
+ intercepts[entity] = val
27
+ return intercepts, slopes
28
+
29
+
30
+ def fit_priority_matrix(
31
+ df: pl.LazyFrame | pl.DataFrame,
32
+ entity_col: str,
33
+ timestamp_col: str,
34
+ count_col: Optional[str] = None,
35
+ date_filter: Optional[str] = None,
36
+ min_observations: int = 3,
37
+ min_total_count: int = 0,
38
+ decline_window_quarters: int = 6,
39
+ temporal_granularity: Literal["yearly", "quarterly", "semiannual"] = "yearly",
40
+ vcp_p: float = DEFAULT_VCP_P,
41
+ fe_p: float = DEFAULT_FE_P,
42
+ ) -> Tuple[pd.DataFrame, Dict]:
43
+ """
44
+ Fit Poisson GLMM to classify entities into priority quadrants.
45
+
46
+ Args:
47
+ df: Input DataFrame
48
+ entity_col: Entity identifier column
49
+ timestamp_col: Date column
50
+ count_col: Count metric column (defaults to row count)
51
+ date_filter: Date filter (e.g., "< 2025-01-01")
52
+ min_observations: Minimum time periods required
53
+ min_total_count: Minimum total count threshold
54
+ decline_window_quarters: Filter entities inactive >N quarters
55
+ temporal_granularity: Time aggregation level
56
+ vcp_p: Random effects prior scale
57
+ fe_p: Fixed effects prior scale
58
+
59
+ Returns:
60
+ Tuple of (results DataFrame, statistics dict)
61
+ """
62
+ # ensure dataframe
63
+ if isinstance(df, pl.LazyFrame):
64
+ df = df.collect()
65
+
66
+ # ensure timestamp column is Date type
67
+ df = df.with_columns(pl.col(timestamp_col).cast(pl.Date))
68
+
69
+ # apply date filter if specified
70
+ if date_filter:
71
+ date_filter_clean = date_filter.strip()
72
+
73
+ # parse filter operators
74
+ if date_filter_clean.startswith("<="):
75
+ date_value = date_filter_clean[2:].strip()
76
+ df = df.filter(
77
+ pl.col(timestamp_col) <= pl.lit(date_value).cast(pl.Date)
78
+ )
79
+ elif date_filter_clean.startswith(">="):
80
+ date_value = date_filter_clean[2:].strip()
81
+ df = df.filter(
82
+ pl.col(timestamp_col) >= pl.lit(date_value).cast(pl.Date)
83
+ )
84
+ elif date_filter_clean.startswith("<"):
85
+ date_value = date_filter_clean[1:].strip()
86
+ df = df.filter(
87
+ pl.col(timestamp_col) < pl.lit(date_value).cast(pl.Date)
88
+ )
89
+ elif date_filter_clean.startswith(">"):
90
+ date_value = date_filter_clean[1:].strip()
91
+ df = df.filter(
92
+ pl.col(timestamp_col) > pl.lit(date_value).cast(pl.Date)
93
+ )
94
+ else:
95
+ # assume date only (use < for backward compatibility)
96
+ df = df.filter(
97
+ pl.col(timestamp_col) < pl.lit(date_filter).cast(pl.Date)
98
+ )
99
+
100
+ # filter by minimum total count if specified
101
+ n_before_volume_filter = df.select(entity_col).n_unique()
102
+ if min_total_count > 0:
103
+ total_counts = df.group_by(entity_col).agg(pl.len().alias("total_count"))
104
+ valid_entities = total_counts.filter(
105
+ pl.col("total_count") >= min_total_count
106
+ )[entity_col]
107
+ df = df.filter(pl.col(entity_col).is_in(valid_entities))
108
+ n_after_volume_filter = df.select(entity_col).n_unique()
109
+ n_filtered_volume = n_before_volume_filter - n_after_volume_filter
110
+ if n_filtered_volume > 0:
111
+ print(
112
+ f" Filtered {n_filtered_volume} entities (<{min_total_count} total count)"
113
+ )
114
+
115
+ # filter stale entities (decline window)
116
+ if decline_window_quarters > 0 and temporal_granularity == "quarterly":
117
+ last_observation = df.group_by(entity_col).agg(
118
+ pl.col(timestamp_col).max().alias("last_date")
119
+ )
120
+
121
+ from datetime import timedelta
122
+
123
+ # use max date in dataset for historical analysis
124
+ dataset_max_datetime = df.select(pl.col(timestamp_col).max()).item()
125
+ dataset_max_date = (
126
+ dataset_max_datetime.date()
127
+ if hasattr(dataset_max_datetime, "date")
128
+ else dataset_max_datetime
129
+ )
130
+ decline_cutoff = dataset_max_date - timedelta(
131
+ days=decline_window_quarters * 91 # ~91 days per quarter
132
+ )
133
+
134
+ n_before_decline_filter = df.select(entity_col).n_unique()
135
+ stale_entities = last_observation.filter(
136
+ pl.col("last_date").cast(pl.Date) < pl.lit(decline_cutoff).cast(pl.Date)
137
+ )[entity_col]
138
+
139
+ df = df.filter(~pl.col(entity_col).is_in(stale_entities))
140
+ n_after_decline_filter = df.select(entity_col).n_unique()
141
+ n_filtered_stale = n_before_decline_filter - n_after_decline_filter
142
+
143
+ if n_filtered_stale > 0:
144
+ print(
145
+ f" Filtered {n_filtered_stale} entities (inactive >{decline_window_quarters}Q)"
146
+ )
147
+
148
+ # auto-adjust min_observations for temporal granularity
149
+ if min_observations == 3 and temporal_granularity == "quarterly":
150
+ min_observations = 8 # 2 years quarterly
151
+ elif min_observations == 3 and temporal_granularity == "semiannual":
152
+ min_observations = 4 # 2 years semiannual
153
+
154
+ # prepare aggregation based on temporal granularity
155
+ if temporal_granularity == "quarterly":
156
+ df = df.with_columns(
157
+ [
158
+ pl.col(timestamp_col).dt.year().alias("year"),
159
+ pl.col(timestamp_col).dt.quarter().alias("quarter"),
160
+ ]
161
+ )
162
+
163
+ if count_col:
164
+ df_prepared = (
165
+ df.group_by(["year", "quarter", entity_col])
166
+ .agg(pl.col(count_col).sum().alias("count"))
167
+ .sort(["year", "quarter", entity_col])
168
+ )
169
+ else:
170
+ df_prepared = (
171
+ df.group_by(["year", "quarter", entity_col])
172
+ .agg(pl.len().alias("count"))
173
+ .sort(["year", "quarter", entity_col])
174
+ )
175
+
176
+ elif temporal_granularity == "semiannual":
177
+ df = df.with_columns(
178
+ [
179
+ pl.col(timestamp_col).dt.year().alias("year"),
180
+ pl.col(timestamp_col).dt.quarter().alias("quarter"),
181
+ ]
182
+ ).with_columns(
183
+ # semester: Q1-Q2 = 1, Q3-Q4 = 2
184
+ pl.when(pl.col("quarter") <= 2)
185
+ .then(pl.lit(1))
186
+ .otherwise(pl.lit(2))
187
+ .alias("semester")
188
+ )
189
+
190
+ if count_col:
191
+ df_prepared = (
192
+ df.group_by(["year", "semester", entity_col])
193
+ .agg(pl.col(count_col).sum().alias("count"))
194
+ .sort(["year", "semester", entity_col])
195
+ )
196
+ else:
197
+ df_prepared = (
198
+ df.group_by(["year", "semester", entity_col])
199
+ .agg(pl.len().alias("count"))
200
+ .sort(["year", "semester", entity_col])
201
+ )
202
+
203
+ else: # yearly
204
+ df = df.with_columns(pl.col(timestamp_col).dt.year().alias("year"))
205
+
206
+ if count_col:
207
+ df_prepared = (
208
+ df.group_by(["year", entity_col])
209
+ .agg(pl.col(count_col).sum().alias("count"))
210
+ .sort(["year", entity_col])
211
+ )
212
+ else:
213
+ df_prepared = (
214
+ df.group_by(["year", entity_col])
215
+ .agg(pl.len().alias("count"))
216
+ .sort(["year", entity_col])
217
+ )
218
+
219
+ # filter entities with sufficient observations
220
+ if min_observations > 0:
221
+ entity_counts = df_prepared.group_by(entity_col).agg(
222
+ pl.len().alias("n_periods")
223
+ )
224
+ valid_entities = entity_counts.filter(
225
+ pl.col("n_periods") >= min_observations
226
+ )[entity_col]
227
+ df_prepared = df_prepared.filter(pl.col(entity_col).is_in(valid_entities))
228
+
229
+ # ensure count is integer
230
+ df_prepared = df_prepared.with_columns(pl.col("count").cast(pl.Int64))
231
+
232
+ # create time variable based on temporal granularity
233
+ if temporal_granularity == "quarterly":
234
+ # continuous quarterly time: year + (quarter-1)/4
235
+ df_prepared = df_prepared.with_columns(
236
+ (pl.col("year") + (pl.col("quarter") - 1) / 4).alias("time_continuous")
237
+ )
238
+
239
+ # center for numerical stability
240
+ mean_time = df_prepared["time_continuous"].mean()
241
+ df_prepared = df_prepared.with_columns(
242
+ (pl.col("time_continuous") - mean_time).alias("time")
243
+ )
244
+
245
+ elif temporal_granularity == "semiannual":
246
+ # continuous semiannual time: year + (semester-1)/2
247
+ df_prepared = df_prepared.with_columns(
248
+ (pl.col("year") + (pl.col("semester") - 1) / 2).alias("time_continuous")
249
+ )
250
+
251
+ # center for numerical stability
252
+ mean_time = df_prepared["time_continuous"].mean()
253
+ df_prepared = df_prepared.with_columns(
254
+ (pl.col("time_continuous") - mean_time).alias("time")
255
+ )
256
+
257
+ else: # yearly
258
+ # center year for numerical stability
259
+ mean_year = df_prepared["year"].mean()
260
+ df_prepared = df_prepared.with_columns(
261
+ (pl.col("year") - mean_year).alias("time")
262
+ )
263
+
264
+ # ensure categorical type for entity
265
+ df_prepared = df_prepared.with_columns(pl.col(entity_col).cast(pl.Categorical))
266
+
267
+ # make period categorical for seasonal effects
268
+ if temporal_granularity == "quarterly":
269
+ df_prepared = df_prepared.with_columns(pl.col("quarter").cast(pl.Categorical))
270
+ elif temporal_granularity == "semiannual":
271
+ df_prepared = df_prepared.with_columns(pl.col("semester").cast(pl.Categorical))
272
+
273
+ # ensure positive counts for poisson
274
+ df_prepared = df_prepared.filter(pl.col("count") > 0)
275
+
276
+ # convert to pandas for statsmodels
277
+ df_pandas = df_prepared.to_pandas()
278
+ df_pandas["time"] = df_pandas["time"].astype(float)
279
+ df_pandas[entity_col] = df_pandas[entity_col].astype("category")
280
+
281
+ # build fixed-effect formula with seasonal dummies
282
+ formula = "count ~ time"
283
+ if temporal_granularity == "quarterly":
284
+ df_pandas["quarter"] = df_pandas["quarter"].astype("category")
285
+ formula += " + C(quarter)"
286
+ elif temporal_granularity == "semiannual":
287
+ df_pandas["semester"] = df_pandas["semester"].astype("category")
288
+ formula += " + C(semester)"
289
+
290
+ # random effects: intercept + slope per entity
291
+ random_formulas = {
292
+ "re_int": f"0 + C({entity_col})",
293
+ "re_slope": f"0 + C({entity_col}):time",
294
+ }
295
+
296
+ # fit poisson bayesian mixed model
297
+ glmm_model = PoissonBayesMixedGLM.from_formula(
298
+ formula,
299
+ random_formulas,
300
+ df_pandas,
301
+ vcp_p=vcp_p,
302
+ fe_p=fe_p,
303
+ )
304
+
305
+ # use variational bayes (returns posterior mean)
306
+ # avoids boundary convergence issues vs map
307
+ glmm_result = glmm_model.fit_vb()
308
+
309
+ # extract random effects
310
+ intercepts_dict, slopes_dict = _extract_random_effects(glmm_model, glmm_result)
311
+
312
+ # convert to lists for dataframe
313
+ entities = list(intercepts_dict.keys())
314
+ intercepts = [intercepts_dict[ent] for ent in entities]
315
+ slopes = [slopes_dict[ent] for ent in entities]
316
+
317
+ # create results dataframe
318
+ df_random_effects = pl.DataFrame(
319
+ {"entity": entities, "Random_Intercept": intercepts, "Random_Slope": slopes}
320
+ )
321
+
322
+ # calculate totals from original filtered data
323
+ df_totals = (
324
+ df.group_by(entity_col)
325
+ .agg(pl.len().alias("count"))
326
+ .sort(entity_col)
327
+ )
328
+
329
+ # convert to pandas for merging
330
+ df_random_effects_pd = df_random_effects.to_pandas()
331
+ df_totals_pd = df_totals.to_pandas()
332
+
333
+ # merge
334
+ results_df = df_random_effects_pd.merge(
335
+ df_totals_pd, left_on="entity", right_on=entity_col, how="left"
336
+ )
337
+
338
+ # import quadrant classifier
339
+ from .quadrants import classify_quadrant
340
+
341
+ # add quadrant classification
342
+ results_df["quadrant"] = results_df.apply(
343
+ lambda row: classify_quadrant(
344
+ row["Random_Intercept"],
345
+ row["Random_Slope"],
346
+ count=row.get("count"),
347
+ min_q1_count=50, # crisis threshold
348
+ ),
349
+ axis=1,
350
+ )
351
+
352
+ # model statistics
353
+ model_stats = {
354
+ "n_entities": len(results_df),
355
+ "n_observations": len(df_prepared),
356
+ "method": "VB",
357
+ "vcp_p": vcp_p,
358
+ "fe_p": fe_p,
359
+ "temporal_granularity": temporal_granularity,
360
+ }
361
+
362
+ # add fixed effects if available
363
+ try:
364
+ if hasattr(glmm_result, "params"):
365
+ params = glmm_result.params
366
+ model_stats["fixed_intercept"] = float(
367
+ params.get("Intercept", params.get("(Intercept)", 0.0))
368
+ )
369
+ model_stats["fixed_slope"] = float(params.get("time", 0.0))
370
+ else:
371
+ model_stats["fixed_intercept"] = None
372
+ model_stats["fixed_slope"] = None
373
+ except Exception:
374
+ model_stats["fixed_intercept"] = None
375
+ model_stats["fixed_slope"] = None
376
+
377
+ return results_df, model_stats
@@ -0,0 +1,60 @@
1
+ """Quadrant classification for priority matrices."""
2
+
3
+
4
+ def classify_quadrant(
5
+ intercept: float,
6
+ slope: float,
7
+ count: float = None,
8
+ min_q1_count: int = 50
9
+ ) -> str:
10
+ """
11
+ Classify entity into priority quadrant.
12
+
13
+ Args:
14
+ intercept: Random intercept (volume indicator)
15
+ slope: Random slope (growth indicator)
16
+ count: Absolute count for Q1 threshold
17
+ min_q1_count: Minimum count for Crisis classification
18
+
19
+ Returns:
20
+ Quadrant code: Q1 (Crisis), Q2 (Investigate), Q3 (Monitor), Q4 (Low)
21
+ """
22
+ if intercept > 0 and slope > 0:
23
+ # would be Q1, but check absolute count threshold
24
+ if count is not None and count < min_q1_count:
25
+ return "Q2" # high growth but insufficient count → investigate
26
+ return "Q1" # crisis
27
+ elif intercept <= 0 and slope > 0:
28
+ return "Q2" # investigate
29
+ elif intercept <= 0 and slope <= 0:
30
+ return "Q3" # monitor
31
+ else:
32
+ return "Q4" # low priority
33
+
34
+
35
+ def get_quadrant_label(quadrant_code: str, x_label: str = "Volume", y_label: str = "Growth") -> str:
36
+ """Get human-readable label for quadrant code.
37
+
38
+ Args:
39
+ quadrant_code: Q1-Q4
40
+ x_label: Label for X-axis metric (e.g., "Volume", "Market Share", "Bugs")
41
+ y_label: Label for Y-axis metric (e.g., "Growth", "Incident Rate")
42
+ """
43
+ labels = {
44
+ "Q1": f"Q1 (High {x_label}, High {y_label})",
45
+ "Q2": f"Q2 (Low {x_label}, High {y_label})",
46
+ "Q3": f"Q3 (Low {x_label}, Low {y_label})",
47
+ "Q4": f"Q4 (High {x_label}, Low {y_label})",
48
+ }
49
+ return labels.get(quadrant_code, quadrant_code)
50
+
51
+
52
+ def get_risk_level(quadrant_code: str) -> str:
53
+ """Map quadrant to risk level."""
54
+ risk_map = {
55
+ "Q1": "Critical",
56
+ "Q2": "Investigate",
57
+ "Q3": "Monitor",
58
+ "Q4": "Low",
59
+ }
60
+ return risk_map.get(quadrant_code, "Unknown")
File without changes
File without changes