priorityx 0.1.2__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.
@@ -0,0 +1,569 @@
1
+ """Transition driver analysis for identifying root causes of quadrant transitions."""
2
+
3
+ import polars as pl
4
+ import pandas as pd
5
+ from typing import Dict, List, Optional, Tuple
6
+ from collections import Counter
7
+ import re
8
+ from datetime import datetime
9
+
10
+
11
+ def _calculate_quarter_dates(quarter_str: str) -> Tuple[str, str]:
12
+ """
13
+ Convert quarter string to start/end dates.
14
+
15
+ Args:
16
+ quarter_str: Quarter in format "YYYY-QN" (e.g., "2024-Q3")
17
+
18
+ Returns:
19
+ Tuple of (start_date, end_date) as ISO strings
20
+
21
+ Examples:
22
+ >>> _calculate_quarter_dates("2024-Q3")
23
+ ('2024-04-01', '2024-07-01')
24
+ """
25
+ match = re.match(r"(\d{4})-Q(\d)", quarter_str)
26
+ if not match:
27
+ raise ValueError(f"Invalid quarter format: {quarter_str}. Use 'YYYY-QN' (e.g., '2024-Q3')")
28
+
29
+ year = int(match.group(1))
30
+ quarter = int(match.group(2))
31
+
32
+ # quarter start dates
33
+ quarter_starts = {
34
+ 1: (year - 1, 10, 1), # Q1 starts Oct 1 of prev year
35
+ 2: (year, 1, 1), # Q2 starts Jan 1
36
+ 3: (year, 4, 1), # Q3 starts Apr 1
37
+ 4: (year, 7, 1), # Q4 starts Jul 1
38
+ }
39
+
40
+ if quarter not in quarter_starts:
41
+ raise ValueError(f"Quarter must be 1-4, got: {quarter}")
42
+
43
+ y, m, d = quarter_starts[quarter]
44
+ start_date = datetime(y, m, d)
45
+
46
+ # calculate next quarter start (exact end date)
47
+ next_quarter = quarter + 1 if quarter < 4 else 1
48
+ next_year = year if quarter < 4 else year + 1
49
+ ey, em, ed = quarter_starts[next_quarter]
50
+ if next_quarter == 1:
51
+ ey = next_year
52
+ end_date = datetime(ey, em, ed)
53
+
54
+ return start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d")
55
+
56
+
57
+ def classify_priority(
58
+ from_quadrant: str,
59
+ to_quadrant: str,
60
+ x: float,
61
+ y: float,
62
+ x_delta: float,
63
+ y_delta: float,
64
+ count_delta: int,
65
+ percent_change: float,
66
+ ) -> Tuple[int, str, Optional[str]]:
67
+ """
68
+ Classify supervisory priority based on transition characteristics.
69
+
70
+ 4-tier system:
71
+ - Priority 1 (Critical): Extreme movement or explosion
72
+ - Priority 2 (Investigate): Significant velocity or growth shock
73
+ - Priority 3 (Monitor): Borderline or threshold crossing
74
+ - Priority 4 (Low): Routine or improving
75
+
76
+ Args:
77
+ from_quadrant: Starting quadrant code
78
+ to_quadrant: Ending quadrant code
79
+ x: Current X-axis position
80
+ y: Current Y-axis position
81
+ x_delta: X-axis change
82
+ y_delta: Y-axis change
83
+ count_delta: Absolute count change
84
+ percent_change: Percent change in count
85
+
86
+ Returns:
87
+ Tuple of (priority, reason, spike_axis) where:
88
+ - priority: 1=Critical, 2=Investigate, 3=Monitor, 4=Low
89
+ - reason: Explanation string
90
+ - spike_axis: 'Y', 'X', 'XY', or None
91
+
92
+ Examples:
93
+ >>> classify_priority("Q3", "Q1", 0.5, 0.6, 0.5, 0.5, 100, 200)
94
+ (1, 'Critical: Extreme movement (ΔX=0.50, ΔY=0.50)', 'XY')
95
+ """
96
+ is_borderline = abs(x) <= 0.1 or abs(y) <= 0.1
97
+ to_critical = to_quadrant == "Q1"
98
+
99
+ # crisis triggers (0.4 = 2.74 SD, aligns with 3-sigma rule)
100
+ y_spike = abs(y_delta) > 0.4
101
+ x_spike = abs(x_delta) > 0.4
102
+ explosion = percent_change > 500 and count_delta > 50
103
+
104
+ # priority 1: critical
105
+ if y_spike or x_spike or explosion:
106
+ if y_spike and x_spike:
107
+ spike_axis = "XY"
108
+ elif y_spike:
109
+ spike_axis = "Y"
110
+ elif x_spike:
111
+ spike_axis = "X"
112
+ else:
113
+ spike_axis = "Y" # default for explosions
114
+
115
+ return 1, f"Critical: Extreme movement (dX={x_delta:.2f}, dY={y_delta:.2f})", spike_axis
116
+
117
+ # priority 2: investigate
118
+ if abs(x_delta) > 0.15 or abs(y_delta) > 0.15:
119
+ return 2, f"Velocity trigger (dX={x_delta:.2f}, dY={y_delta:.2f})", None
120
+
121
+ if to_critical and not is_borderline:
122
+ return 2, "Critical destination, clear (to Q1)", None
123
+
124
+ if percent_change > 100 and count_delta >= 5:
125
+ return 2, f"Growth shock (+{count_delta}, {percent_change:.0f}%)", None
126
+
127
+ # priority 3: monitor
128
+ if is_borderline or to_critical:
129
+ return 3, "Borderline/threshold crossing", None
130
+
131
+ # priority 4: low
132
+ return 4, "Routine/improving", None
133
+
134
+
135
+ def extract_transition_drivers(
136
+ movement_df: pd.DataFrame,
137
+ df_raw: pl.DataFrame | pl.LazyFrame,
138
+ entity_name: str,
139
+ quarter_from: str,
140
+ quarter_to: str,
141
+ entity_col: str = "entity",
142
+ timestamp_col: str = "date",
143
+ subcategory_cols: Optional[List[str]] = None,
144
+ text_col: Optional[str] = None,
145
+ ) -> Dict:
146
+ """
147
+ Extract key drivers of a quadrant transition.
148
+
149
+ Analyzes what drove a specific entity to transition between quadrants,
150
+ including volume changes, growth changes, and contributing sub-categories.
151
+
152
+ Args:
153
+ movement_df: Output from track_cumulative_movement()
154
+ df_raw: Raw event data (polars DataFrame)
155
+ entity_name: Entity to analyze
156
+ quarter_from: Starting quarter (e.g., "2024-Q2")
157
+ quarter_to: Ending quarter (e.g., "2024-Q3")
158
+ entity_col: Entity column name in df_raw
159
+ timestamp_col: Timestamp column name in df_raw
160
+ subcategory_cols: Optional list of subcategory columns to analyze
161
+ text_col: Optional text column for keyword analysis
162
+
163
+ Returns:
164
+ Dictionary with structure:
165
+ {
166
+ "transition": {...}, # Overview
167
+ "magnitude": {...}, # Volume/growth changes
168
+ "subcategory_drivers": {...}, # Drivers by subcategory (if provided)
169
+ "keyword_drivers": [...], # Text analysis (if provided)
170
+ "priority": {...} # Priority classification
171
+ }
172
+
173
+ Examples:
174
+ >>> # Basic analysis
175
+ >>> analysis = extract_transition_drivers(
176
+ ... movement_df, df, "Service A", "2024-Q2", "2024-Q3",
177
+ ... entity_col="service", timestamp_col="date"
178
+ ... )
179
+
180
+ >>> # With subcategories
181
+ >>> analysis = extract_transition_drivers(
182
+ ... movement_df, df, "FSP X", "2024-Q2", "2024-Q3",
183
+ ... subcategory_cols=["topic", "product"]
184
+ ... )
185
+ """
186
+ if isinstance(df_raw, pl.LazyFrame):
187
+ df_raw = df_raw.collect()
188
+
189
+ # get transition data from movement_df
190
+ entity_data = movement_df[movement_df["entity"] == entity_name]
191
+
192
+ if len(entity_data) == 0:
193
+ raise ValueError(f"Entity '{entity_name}' not found in movement data")
194
+
195
+ from_data = entity_data[entity_data["quarter"] == quarter_from]
196
+ to_data = entity_data[entity_data["quarter"] == quarter_to]
197
+
198
+ if len(from_data) == 0:
199
+ raise ValueError(f"Quarter '{quarter_from}' not found for '{entity_name}'")
200
+ if len(to_data) == 0:
201
+ raise ValueError(f"Quarter '{quarter_to}' not found for '{entity_name}'")
202
+
203
+ from_row = from_data.iloc[0]
204
+ to_row = to_data.iloc[0]
205
+
206
+ from_quadrant = from_row["period_quadrant"]
207
+ to_quadrant = to_row["period_quadrant"]
208
+
209
+ # transition overview
210
+ transition_overview = {
211
+ "entity": entity_name,
212
+ "from_quarter": quarter_from,
213
+ "to_quarter": quarter_to,
214
+ "from_quadrant": from_quadrant,
215
+ "to_quadrant": to_quadrant,
216
+ "quadrant_changed": from_quadrant != to_quadrant,
217
+ }
218
+
219
+ # magnitude metrics
220
+ if "complaints_to_date" in from_row.index:
221
+ count_col_name = "complaints_to_date"
222
+ elif "count_to_date" in from_row.index:
223
+ count_col_name = "count_to_date"
224
+ else:
225
+ count_col_name = None
226
+
227
+ if count_col_name:
228
+ count_from = int(from_row[count_col_name])
229
+ count_to = int(to_row[count_col_name])
230
+ absolute_delta = count_to - count_from
231
+ percent_change = (absolute_delta / count_from * 100) if count_from > 0 else 0
232
+ else:
233
+ count_from = 0
234
+ count_to = 0
235
+ absolute_delta = 0
236
+ percent_change = 0
237
+
238
+ magnitude_metrics = {
239
+ "volume_change": {
240
+ "count_from": count_from,
241
+ "count_to": count_to,
242
+ "absolute_delta": absolute_delta,
243
+ "percent_change": round(percent_change, 1),
244
+ "x_from": round(from_row["period_x"], 2),
245
+ "x_to": round(to_row["period_x"], 2),
246
+ "x_delta": round(to_row["period_x"] - from_row["period_x"], 2),
247
+ },
248
+ "growth_change": {
249
+ "y_from": round(from_row["period_y"], 2),
250
+ "y_to": round(to_row["period_y"], 2),
251
+ "y_delta": round(to_row["period_y"] - from_row["period_y"], 2),
252
+ },
253
+ }
254
+
255
+ # priority classification
256
+ priority, reason, spike_axis = classify_priority(
257
+ from_quadrant=from_quadrant,
258
+ to_quadrant=to_quadrant,
259
+ x=to_row["period_x"],
260
+ y=to_row["period_y"],
261
+ x_delta=to_row["period_x"] - from_row["period_x"],
262
+ y_delta=to_row["period_y"] - from_row["period_y"],
263
+ count_delta=absolute_delta,
264
+ percent_change=percent_change,
265
+ )
266
+
267
+ priority_classification = {
268
+ "priority": priority,
269
+ "priority_name": {1: "Critical", 2: "Investigate", 3: "Monitor", 4: "Low"}[priority],
270
+ "trigger_reason": reason,
271
+ "spike_axis": spike_axis,
272
+ "is_borderline": abs(to_row["period_x"]) <= 0.1 or abs(to_row["period_y"]) <= 0.1,
273
+ }
274
+
275
+ # initialize result
276
+ result = {
277
+ "transition": transition_overview,
278
+ "magnitude": magnitude_metrics,
279
+ "priority": priority_classification,
280
+ }
281
+
282
+ # subcategory drivers (optional)
283
+ if subcategory_cols:
284
+ result["subcategory_drivers"] = _analyze_subcategory_drivers(
285
+ df_raw, entity_name, entity_col, timestamp_col,
286
+ quarter_from, quarter_to, subcategory_cols, absolute_delta
287
+ )
288
+
289
+ # keyword drivers (optional)
290
+ if text_col:
291
+ result["keyword_drivers"] = _analyze_keyword_drivers(
292
+ df_raw, entity_name, entity_col, timestamp_col,
293
+ quarter_from, quarter_to, text_col
294
+ )
295
+
296
+ return result
297
+
298
+
299
+ def _analyze_subcategory_drivers(
300
+ df_raw: pl.DataFrame,
301
+ entity_name: str,
302
+ entity_col: str,
303
+ timestamp_col: str,
304
+ quarter_from: str,
305
+ quarter_to: str,
306
+ subcategory_cols: List[str],
307
+ period_increase: int,
308
+ ) -> Dict:
309
+ """Analyze which subcategories drove the transition."""
310
+ from_start, from_end = _calculate_quarter_dates(quarter_from)
311
+ to_start, to_end = _calculate_quarter_dates(quarter_to)
312
+
313
+ # filter to entity and quarters
314
+ entity_data = df_raw.filter(pl.col(entity_col) == entity_name)
315
+
316
+ from_data = entity_data.filter(
317
+ (pl.col(timestamp_col) >= pl.lit(from_start).str.to_date())
318
+ & (pl.col(timestamp_col) < pl.lit(from_end).str.to_date())
319
+ )
320
+
321
+ to_data = entity_data.filter(
322
+ (pl.col(timestamp_col) >= pl.lit(to_start).str.to_date())
323
+ & (pl.col(timestamp_col) < pl.lit(to_end).str.to_date())
324
+ )
325
+
326
+ drivers_by_subcategory = {}
327
+
328
+ for subcat_col in subcategory_cols:
329
+ if subcat_col not in df_raw.columns:
330
+ continue
331
+
332
+ # aggregate by subcategory
333
+ from_subcat = (
334
+ from_data.group_by(subcat_col)
335
+ .agg(pl.len().alias("count"))
336
+ .sort("count", descending=True)
337
+ ).to_pandas()
338
+
339
+ to_subcat = (
340
+ to_data.group_by(subcat_col)
341
+ .agg(pl.len().alias("count"))
342
+ .sort("count", descending=True)
343
+ ).to_pandas()
344
+
345
+ # merge and calculate deltas
346
+ comparison = pd.merge(
347
+ from_subcat,
348
+ to_subcat,
349
+ on=subcat_col,
350
+ how="outer",
351
+ suffixes=("_from", "_to"),
352
+ ).fillna(0)
353
+
354
+ comparison["delta"] = comparison["count_to"] - comparison["count_from"]
355
+ comparison["percent_of_change"] = (
356
+ (comparison["delta"] / period_increase * 100) if period_increase > 0 else 0
357
+ )
358
+
359
+ # classify driver type
360
+ def classify_driver_type(row):
361
+ if row["count_from"] == 0:
362
+ return "new_emergence"
363
+ elif row["delta"] > row["count_from"]:
364
+ return "acceleration"
365
+ else:
366
+ return "deceleration"
367
+
368
+ comparison["driver_type"] = comparison.apply(classify_driver_type, axis=1)
369
+
370
+ # get top 3 by absolute delta
371
+ top_drivers = (
372
+ comparison[comparison["delta"] > 0]
373
+ .nlargest(3, "delta")
374
+ .to_dict("records")
375
+ )
376
+
377
+ drivers_list = []
378
+ for driver in top_drivers:
379
+ drivers_list.append({
380
+ "name": driver[subcat_col],
381
+ "count_from": int(driver["count_from"]),
382
+ "count_to": int(driver["count_to"]),
383
+ "delta": int(driver["delta"]),
384
+ "percent_of_change": round(driver["percent_of_change"], 1),
385
+ "driver_type": driver["driver_type"],
386
+ })
387
+
388
+ # calculate explanation power
389
+ top_explain_pct = (
390
+ sum([d["delta"] for d in drivers_list]) / period_increase * 100
391
+ if period_increase > 0 else 0
392
+ )
393
+
394
+ drivers_by_subcategory[subcat_col] = {
395
+ "top_drivers": drivers_list,
396
+ "top_3_explain_pct": round(top_explain_pct, 1),
397
+ }
398
+
399
+ return drivers_by_subcategory
400
+
401
+
402
+ def _analyze_keyword_drivers(
403
+ df_raw: pl.DataFrame,
404
+ entity_name: str,
405
+ entity_col: str,
406
+ timestamp_col: str,
407
+ quarter_from: str,
408
+ quarter_to: str,
409
+ text_col: str,
410
+ top_n: int = 10,
411
+ ) -> List[Dict]:
412
+ """Extract emerging keywords from text using bigram analysis."""
413
+ from_start, from_end = _calculate_quarter_dates(quarter_from)
414
+ to_start, to_end = _calculate_quarter_dates(quarter_to)
415
+
416
+ # filter to entity and quarters
417
+ entity_data = df_raw.filter(pl.col(entity_col) == entity_name)
418
+
419
+ from_data = entity_data.filter(
420
+ (pl.col(timestamp_col) >= pl.lit(from_start).str.to_date())
421
+ & (pl.col(timestamp_col) < pl.lit(from_end).str.to_date())
422
+ )
423
+
424
+ to_data = entity_data.filter(
425
+ (pl.col(timestamp_col) >= pl.lit(to_start).str.to_date())
426
+ & (pl.col(timestamp_col) < pl.lit(to_end).str.to_date())
427
+ )
428
+
429
+ def extract_bigrams(text: str) -> List[str]:
430
+ """Extract meaningful bigrams from text."""
431
+ if not text or pd.isna(text):
432
+ return []
433
+
434
+ text = str(text).lower()
435
+ words = re.findall(r'\b\w+\b', text)
436
+
437
+ # basic stopwords (language-agnostic)
438
+ stopwords = {
439
+ 'the', 'is', 'in', 'to', 'of', 'and', 'a', 'an', 'with', 'that', 'this',
440
+ 'has', 'have', 'been', 'was', 'were', 'are', 'will', 'would', 'could',
441
+ 'for', 'from', 'by', 'on', 'at', 'as', 'be', 'it', 'or', 'not'
442
+ }
443
+
444
+ # filter short words and stopwords
445
+ words = [w for w in words if len(w) > 2 and w not in stopwords]
446
+
447
+ # create bigrams
448
+ bigrams = [f"{words[i]} {words[i+1]}" for i in range(len(words) - 1)]
449
+
450
+ # filter numeric-only bigrams
451
+ bigrams = [b for b in bigrams if not all(c.isdigit() or c == ' ' for c in b)]
452
+
453
+ return bigrams
454
+
455
+ # extract bigrams
456
+ from_text = from_data.select(text_col).to_series().to_list()
457
+ to_text = to_data.select(text_col).to_series().to_list()
458
+
459
+ from_bigrams = []
460
+ for text in from_text:
461
+ from_bigrams.extend(extract_bigrams(text))
462
+
463
+ to_bigrams = []
464
+ for text in to_text:
465
+ to_bigrams.extend(extract_bigrams(text))
466
+
467
+ # count frequency
468
+ from_counter = Counter(from_bigrams)
469
+ to_counter = Counter(to_bigrams)
470
+
471
+ # find bigrams that increased most
472
+ bigram_changes = {}
473
+ for bigram in set(list(from_counter.keys()) + list(to_counter.keys())):
474
+ from_count = from_counter.get(bigram, 0)
475
+ to_count = to_counter.get(bigram, 0)
476
+ delta = to_count - from_count
477
+ if delta > 0:
478
+ bigram_changes[bigram] = {
479
+ 'from': from_count,
480
+ 'to': to_count,
481
+ 'delta': delta
482
+ }
483
+
484
+ # get top N emerging bigrams
485
+ top_bigrams = sorted(bigram_changes.items(), key=lambda x: x[1]['delta'], reverse=True)[:top_n]
486
+
487
+ keyword_drivers = []
488
+ for bigram, stats in top_bigrams:
489
+ keyword_drivers.append({
490
+ 'bigram': bigram,
491
+ 'frequency_from': stats['from'],
492
+ 'frequency_to': stats['to'],
493
+ 'delta': stats['delta']
494
+ })
495
+
496
+ return keyword_drivers
497
+
498
+
499
+ def display_transition_drivers(analysis: Dict, show_keywords: bool = False) -> None:
500
+ """
501
+ Print transition driver analysis in human-readable format.
502
+
503
+ Args:
504
+ analysis: Output from extract_transition_drivers()
505
+ show_keywords: Whether to show keyword analysis
506
+
507
+ Examples:
508
+ >>> display_transition_drivers(analysis)
509
+ """
510
+ trans = analysis["transition"]
511
+ mag = analysis["magnitude"]
512
+ vol = mag["volume_change"]
513
+ growth = mag["growth_change"]
514
+ priority = analysis["priority"]
515
+
516
+ print()
517
+ print("TRANSITION DRIVER ANALYSIS")
518
+
519
+ # transition overview
520
+ print()
521
+ print(f"Entity: {trans['entity']}")
522
+ print(f"Period: {trans['from_quarter']} -> {trans['to_quarter']}")
523
+ print(f"Quadrant: {trans['from_quadrant']} -> {trans['to_quadrant']}")
524
+
525
+ if trans['quadrant_changed']:
526
+ print("Status: Quadrant transition detected")
527
+ else:
528
+ print("Status: Within-quadrant movement")
529
+
530
+ # priority
531
+ print()
532
+ print(f"Priority: P{priority['priority']} ({priority['priority_name']})")
533
+ print(f"Reason: {priority['trigger_reason']}")
534
+ if priority.get("spike_axis"):
535
+ print(f"Spike axis: {priority['spike_axis']}")
536
+
537
+ # magnitude
538
+ print()
539
+ print("Magnitude:")
540
+ print(f" Volume: {vol['count_from']:,} -> {vol['count_to']:,} "
541
+ f"(delta {vol['absolute_delta']:+,}, {vol['percent_change']:+.1f}%)")
542
+ print(f" X-axis: {vol['x_from']:.2f} -> {vol['x_to']:.2f} (delta {vol['x_delta']:+.2f})")
543
+ print(f" Y-axis: {growth['y_from']:.2f} -> {growth['y_to']:.2f} (delta {growth['y_delta']:+.2f})")
544
+
545
+ # subcategory drivers
546
+ if "subcategory_drivers" in analysis:
547
+ for subcat_col, drivers_data in analysis["subcategory_drivers"].items():
548
+ top_drivers = drivers_data["top_drivers"]
549
+ explain_pct = drivers_data["top_3_explain_pct"]
550
+
551
+ if top_drivers:
552
+ print()
553
+ print(f"Top drivers by {subcat_col} (explain {explain_pct:.1f}% of change):")
554
+ for i, driver in enumerate(top_drivers, 1):
555
+ print(f" {i}. {driver['name']}")
556
+ print(f" {driver['count_from']:,} -> {driver['count_to']:,} "
557
+ f"(delta {driver['delta']:+,}, {driver['percent_of_change']:.1f}%)")
558
+ print(f" Type: {driver['driver_type']}")
559
+
560
+ # keyword drivers
561
+ if show_keywords and "keyword_drivers" in analysis:
562
+ keywords = analysis["keyword_drivers"]
563
+ if keywords:
564
+ print()
565
+ print("Top emerging keywords:")
566
+ for i, kw in enumerate(keywords[:5], 1):
567
+ print(f" {i}. '{kw['bigram']}' ({kw['frequency_from']} -> {kw['frequency_to']}, delta +{kw['delta']})")
568
+
569
+ print()