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.
- priorityx/__init__.py +7 -0
- priorityx/core/__init__.py +0 -0
- priorityx/core/filters.py +224 -0
- priorityx/core/glmm.py +377 -0
- priorityx/core/quadrants.py +60 -0
- priorityx/drivers/__init__.py +0 -0
- priorityx/tracking/__init__.py +0 -0
- priorityx/tracking/drivers.py +569 -0
- priorityx/tracking/movement.py +483 -0
- priorityx/tracking/transitions.py +214 -0
- priorityx/utils/__init__.py +0 -0
- priorityx/utils/helpers.py +226 -0
- priorityx/viz/__init__.py +0 -0
- priorityx/viz/matrix.py +314 -0
- priorityx/viz/timeline.py +302 -0
- priorityx/viz/trajectory.py +231 -0
- priorityx-0.1.2.dist-info/METADATA +73 -0
- priorityx-0.1.2.dist-info/RECORD +20 -0
- priorityx-0.1.2.dist-info/WHEEL +4 -0
- priorityx-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|