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 +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/movement.py +483 -0
- priorityx/tracking/transitions.py +214 -0
- priorityx/utils/__init__.py +0 -0
- priorityx/utils/helpers.py +227 -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.1.dist-info/METADATA +73 -0
- priorityx-0.1.1.dist-info/RECORD +19 -0
- priorityx-0.1.1.dist-info/WHEEL +4 -0
- priorityx-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Cumulative movement tracking through priority quadrants."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Optional, Sequence, Union
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_quarter_start(ts: pd.Timestamp) -> bool:
|
|
11
|
+
"""Check if timestamp is the first day of a calendar quarter."""
|
|
12
|
+
return ts.day == 1 and ts.month in (1, 4, 7, 10)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _next_quarter_start(ts: pd.Timestamp) -> pd.Timestamp:
|
|
16
|
+
"""Get timestamp for the first day of the next calendar quarter."""
|
|
17
|
+
if ts.month in (1, 4, 7, 10):
|
|
18
|
+
month = ts.month
|
|
19
|
+
else:
|
|
20
|
+
# fallback to nearest lower quarter
|
|
21
|
+
month = ((ts.month - 1) // 3) * 3 + 1
|
|
22
|
+
ts = pd.Timestamp(year=ts.year, month=month, day=1)
|
|
23
|
+
|
|
24
|
+
if month == 1:
|
|
25
|
+
next_month = 4
|
|
26
|
+
next_year = ts.year
|
|
27
|
+
elif month == 4:
|
|
28
|
+
next_month = 7
|
|
29
|
+
next_year = ts.year
|
|
30
|
+
elif month == 7:
|
|
31
|
+
next_month = 10
|
|
32
|
+
next_year = ts.year
|
|
33
|
+
else: # month == 10
|
|
34
|
+
next_month = 1
|
|
35
|
+
next_year = ts.year + 1
|
|
36
|
+
|
|
37
|
+
return pd.Timestamp(year=next_year, month=next_month, day=1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quarter_label(ts: pd.Timestamp) -> str:
|
|
41
|
+
"""Format timestamp into 'YYYY-QX' label."""
|
|
42
|
+
quarter = ((ts.month - 1) // 3) + 1
|
|
43
|
+
return f"{ts.year}-Q{quarter}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_quarter_schedule_from_range(
|
|
47
|
+
start_date: str, end_date: str
|
|
48
|
+
) -> list[tuple[str, str]]:
|
|
49
|
+
"""
|
|
50
|
+
Build quarter schedule from date range.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
start_date: Start date (YYYY-MM-DD), must be quarter boundary
|
|
54
|
+
end_date: End date (YYYY-MM-DD), must be quarter boundary
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of (label, exclusive_end_date) tuples
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If dates are not quarter boundaries or invalid
|
|
61
|
+
"""
|
|
62
|
+
start_ts = pd.Timestamp(start_date)
|
|
63
|
+
end_ts = pd.Timestamp(end_date)
|
|
64
|
+
|
|
65
|
+
if start_ts >= end_ts:
|
|
66
|
+
raise ValueError("Start date must be earlier than end date")
|
|
67
|
+
|
|
68
|
+
if not _is_quarter_start(start_ts) or not _is_quarter_start(end_ts):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Dates must be quarter boundaries (YYYY-01-01, YYYY-04-01, "
|
|
71
|
+
"YYYY-07-01, or YYYY-10-01)"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
schedule = []
|
|
75
|
+
current = start_ts
|
|
76
|
+
while current < end_ts:
|
|
77
|
+
next_start = _next_quarter_start(current)
|
|
78
|
+
if next_start > end_ts:
|
|
79
|
+
next_start = end_ts
|
|
80
|
+
|
|
81
|
+
schedule.append((_quarter_label(current), next_start.strftime("%Y-%m-%d")))
|
|
82
|
+
current = next_start
|
|
83
|
+
|
|
84
|
+
return schedule
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_default_quarter_schedule(
|
|
88
|
+
df: pl.DataFrame, timestamp_col: str
|
|
89
|
+
) -> list[tuple[str, str]]:
|
|
90
|
+
"""
|
|
91
|
+
Create default quarter schedule spanning the dataset.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
df: Input polars DataFrame
|
|
95
|
+
timestamp_col: Column name for timestamp
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
List of (label, exclusive_end_date) tuples
|
|
99
|
+
"""
|
|
100
|
+
if timestamp_col not in df.columns or df.height == 0:
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
min_date = df[timestamp_col].min()
|
|
104
|
+
max_date = df[timestamp_col].max()
|
|
105
|
+
|
|
106
|
+
if min_date is None or max_date is None:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
start_ts = pd.Timestamp(min_date).to_period("Q").to_timestamp(how="start")
|
|
110
|
+
# exclusive boundary: first day of quarter following max_date
|
|
111
|
+
max_quarter_start = pd.Timestamp(max_date).to_period("Q").to_timestamp(how="start")
|
|
112
|
+
end_ts = _next_quarter_start(max_quarter_start)
|
|
113
|
+
|
|
114
|
+
return _build_quarter_schedule_from_range(
|
|
115
|
+
start_ts.strftime("%Y-%m-%d"), end_ts.strftime("%Y-%m-%d")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def normalize_quarter_schedule(
|
|
120
|
+
quarters: Optional[Sequence[Union[tuple[str, str], str]]],
|
|
121
|
+
df: pl.DataFrame,
|
|
122
|
+
timestamp_col: str,
|
|
123
|
+
) -> list[tuple[str, str]]:
|
|
124
|
+
"""
|
|
125
|
+
Normalize quarter schedule to standard format.
|
|
126
|
+
|
|
127
|
+
Accepts three input formats:
|
|
128
|
+
1. Explicit schedule: [("2024-Q2", "2024-07-01"), ...]
|
|
129
|
+
2. Date range: ["2024-01-01", "2025-10-01"] (auto-generates quarters)
|
|
130
|
+
3. None: Auto-detect from data
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
quarters: Quarter specification (see formats above)
|
|
134
|
+
df: Input polars DataFrame
|
|
135
|
+
timestamp_col: Column name for timestamp
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of (quarter_label, exclusive_end_date) tuples
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
>>> # explicit schedule
|
|
142
|
+
>>> normalize_quarter_schedule(
|
|
143
|
+
... [("2024-Q1", "2024-04-01"), ("2024-Q2", "2024-07-01")],
|
|
144
|
+
... df, "date"
|
|
145
|
+
... )
|
|
146
|
+
|
|
147
|
+
>>> # date range (auto-generates quarters)
|
|
148
|
+
>>> normalize_quarter_schedule(
|
|
149
|
+
... ["2024-01-01", "2025-01-01"], df, "date"
|
|
150
|
+
... )
|
|
151
|
+
|
|
152
|
+
>>> # auto-detect from data
|
|
153
|
+
>>> normalize_quarter_schedule(None, df, "date")
|
|
154
|
+
"""
|
|
155
|
+
if quarters is None:
|
|
156
|
+
return _build_default_quarter_schedule(df, timestamp_col)
|
|
157
|
+
|
|
158
|
+
if len(quarters) == 0:
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
# check first element type
|
|
162
|
+
quarters[0]
|
|
163
|
+
|
|
164
|
+
# date range format: ["2024-01-01", "2025-10-01"]
|
|
165
|
+
if len(quarters) == 2 and all(isinstance(q, str) for q in quarters):
|
|
166
|
+
return _build_quarter_schedule_from_range(quarters[0], quarters[1])
|
|
167
|
+
|
|
168
|
+
# explicit schedule format: [("2024-Q2", "2024-07-01"), ...]
|
|
169
|
+
normalized_schedule: list[tuple[str, str]] = []
|
|
170
|
+
for entry in quarters:
|
|
171
|
+
if not isinstance(entry, tuple) or len(entry) != 2:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
"quarters must be a list of (label, cutoff_date) tuples "
|
|
174
|
+
"or two quarter boundary strings"
|
|
175
|
+
)
|
|
176
|
+
label, cutoff = entry
|
|
177
|
+
normalized_schedule.append((label, cutoff))
|
|
178
|
+
|
|
179
|
+
return normalized_schedule
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def track_cumulative_movement(
|
|
183
|
+
df: pl.DataFrame | pl.LazyFrame,
|
|
184
|
+
entity_col: str,
|
|
185
|
+
timestamp_col: str,
|
|
186
|
+
quarters: Optional[Sequence[Union[tuple[str, str], str]]] = None,
|
|
187
|
+
min_total_count: int = 20,
|
|
188
|
+
decline_window_quarters: int = 6,
|
|
189
|
+
temporal_granularity: str = "quarterly",
|
|
190
|
+
vcp_p: float = 3.5,
|
|
191
|
+
fe_p: float = 3.0,
|
|
192
|
+
) -> tuple[pd.DataFrame, dict]:
|
|
193
|
+
"""
|
|
194
|
+
Track entity movement through priority quadrants over time.
|
|
195
|
+
|
|
196
|
+
Uses cumulative data periods to track X/Y movement while maintaining
|
|
197
|
+
stable global baseline classification. Three-step process:
|
|
198
|
+
|
|
199
|
+
1. Global baseline: GLMM on full dataset → stable quadrant assignment
|
|
200
|
+
2. Endpoint cohorting: Define valid entities based on endpoint criteria
|
|
201
|
+
3. Quarter-by-quarter tracking: Track X/Y movement for valid entities
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
df: Input polars DataFrame or LazyFrame
|
|
205
|
+
entity_col: Column name for entity identifier
|
|
206
|
+
timestamp_col: Column name for timestamp (Date type)
|
|
207
|
+
quarters: Quarter specification (see normalize_quarter_schedule)
|
|
208
|
+
min_total_count: Minimum total count for inclusion (default: 20)
|
|
209
|
+
decline_window_quarters: Max quarters after last observation (default: 6)
|
|
210
|
+
temporal_granularity: Time granularity for GLMM ('quarterly', 'yearly', 'semiannual')
|
|
211
|
+
vcp_p: Prior scale for random effects (default: 3.5)
|
|
212
|
+
fe_p: Prior scale for fixed effects (default: 3.0)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple of (movement_df, metadata_dict)
|
|
216
|
+
- movement_df: Quarter-by-quarter tracking with columns:
|
|
217
|
+
[entity, quarter, count_to_date, period_x, period_y, period_quadrant,
|
|
218
|
+
global_quadrant, global_x, global_y, count_total, x_delta, y_delta, volume_delta]
|
|
219
|
+
- metadata_dict: Tracking statistics and configuration
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
>>> # auto-detect quarters from data
|
|
223
|
+
>>> movement_df, meta = track_cumulative_movement(
|
|
224
|
+
... df, entity_col="service", timestamp_col="date"
|
|
225
|
+
... )
|
|
226
|
+
|
|
227
|
+
>>> # specify date range
|
|
228
|
+
>>> movement_df, meta = track_cumulative_movement(
|
|
229
|
+
... df, entity_col="service", timestamp_col="date",
|
|
230
|
+
... quarters=["2024-01-01", "2025-01-01"]
|
|
231
|
+
... )
|
|
232
|
+
"""
|
|
233
|
+
# ensure dataframe
|
|
234
|
+
if isinstance(df, pl.LazyFrame):
|
|
235
|
+
df = df.collect()
|
|
236
|
+
|
|
237
|
+
# normalize quarter schedule
|
|
238
|
+
quarter_schedule = normalize_quarter_schedule(quarters, df, timestamp_col)
|
|
239
|
+
|
|
240
|
+
if len(quarter_schedule) == 0:
|
|
241
|
+
print("Warning: No quarter boundaries available for cumulative tracking")
|
|
242
|
+
empty_df = pd.DataFrame(
|
|
243
|
+
columns=[
|
|
244
|
+
"entity",
|
|
245
|
+
"quarter",
|
|
246
|
+
"count_to_date",
|
|
247
|
+
"period_x",
|
|
248
|
+
"period_y",
|
|
249
|
+
"period_quadrant",
|
|
250
|
+
"global_quadrant",
|
|
251
|
+
"global_x",
|
|
252
|
+
"global_y",
|
|
253
|
+
"count_total",
|
|
254
|
+
]
|
|
255
|
+
)
|
|
256
|
+
metadata = {
|
|
257
|
+
"entities_tracked": 0,
|
|
258
|
+
"quarters_analyzed": 0,
|
|
259
|
+
"total_observations": 0,
|
|
260
|
+
"divergence_rate": 0.0,
|
|
261
|
+
"temporal_granularity": temporal_granularity,
|
|
262
|
+
"quarter_schedule": [],
|
|
263
|
+
}
|
|
264
|
+
return empty_df, metadata
|
|
265
|
+
|
|
266
|
+
print("\nCUMULATIVE MOVEMENT TRACKING")
|
|
267
|
+
|
|
268
|
+
# import fit_priority_matrix
|
|
269
|
+
from ..core.glmm import fit_priority_matrix
|
|
270
|
+
|
|
271
|
+
# step 1: calculate global baseline from full dataset
|
|
272
|
+
print("\n[1/3] Calculating global baseline (FULL dataset)...")
|
|
273
|
+
global_results, _ = fit_priority_matrix(
|
|
274
|
+
df,
|
|
275
|
+
entity_col=entity_col,
|
|
276
|
+
timestamp_col=timestamp_col,
|
|
277
|
+
min_observations=0,
|
|
278
|
+
min_total_count=min_total_count,
|
|
279
|
+
decline_window_quarters=0, # don't filter for global baseline
|
|
280
|
+
temporal_granularity=temporal_granularity,
|
|
281
|
+
vcp_p=vcp_p,
|
|
282
|
+
fe_p=fe_p,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
print(f"Global baseline: {len(global_results)} entities")
|
|
286
|
+
|
|
287
|
+
# create global baseline lookup
|
|
288
|
+
global_baseline = {}
|
|
289
|
+
for _, row in global_results.iterrows():
|
|
290
|
+
entity = row[entity_col]
|
|
291
|
+
global_baseline[entity] = {
|
|
292
|
+
"global_quadrant": row["quadrant"],
|
|
293
|
+
"global_x": row["Random_Intercept"],
|
|
294
|
+
"global_y": row["Random_Slope"],
|
|
295
|
+
"count_total": row["count"],
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# step 2: determine valid entities at analysis endpoint
|
|
299
|
+
print("\n[2/3] Determining valid entities at analysis endpoint...")
|
|
300
|
+
|
|
301
|
+
# get endpoint (last quarter)
|
|
302
|
+
final_quarter_name, final_quarter_date = quarter_schedule[-1]
|
|
303
|
+
print(f"Analysis endpoint: {final_quarter_name} ({final_quarter_date})")
|
|
304
|
+
|
|
305
|
+
# calculate valid entities based on cumulative totals up to endpoint
|
|
306
|
+
endpoint_pl = pl.lit(final_quarter_date).str.to_date()
|
|
307
|
+
cumulative_up_to_endpoint = df.filter(pl.col(timestamp_col) < endpoint_pl)
|
|
308
|
+
|
|
309
|
+
# apply filters in same order as fit_priority_matrix for consistency
|
|
310
|
+
|
|
311
|
+
# step 1: filter by min_total_count
|
|
312
|
+
endpoint_totals = cumulative_up_to_endpoint.group_by(entity_col).agg(
|
|
313
|
+
pl.len().alias("total")
|
|
314
|
+
)
|
|
315
|
+
entities_above_threshold = endpoint_totals.filter(
|
|
316
|
+
pl.col("total") >= min_total_count
|
|
317
|
+
)[entity_col]
|
|
318
|
+
cumulative_up_to_endpoint = cumulative_up_to_endpoint.filter(
|
|
319
|
+
pl.col(entity_col).is_in(entities_above_threshold)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# step 2: apply decline_window filter
|
|
323
|
+
if decline_window_quarters > 0:
|
|
324
|
+
dataset_max_datetime = cumulative_up_to_endpoint.select(
|
|
325
|
+
pl.col(timestamp_col).max()
|
|
326
|
+
).item()
|
|
327
|
+
dataset_max_date = (
|
|
328
|
+
dataset_max_datetime.date()
|
|
329
|
+
if hasattr(dataset_max_datetime, "date")
|
|
330
|
+
else dataset_max_datetime
|
|
331
|
+
)
|
|
332
|
+
decline_cutoff = dataset_max_date - timedelta(
|
|
333
|
+
days=decline_window_quarters * 91
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
last_observation_at_endpoint = cumulative_up_to_endpoint.group_by(
|
|
337
|
+
entity_col
|
|
338
|
+
).agg(pl.col(timestamp_col).max().alias("last_date"))
|
|
339
|
+
stale_entities_at_endpoint = last_observation_at_endpoint.filter(
|
|
340
|
+
pl.col("last_date").cast(pl.Date) < pl.lit(decline_cutoff).cast(pl.Date)
|
|
341
|
+
)[entity_col]
|
|
342
|
+
cumulative_up_to_endpoint = cumulative_up_to_endpoint.filter(
|
|
343
|
+
~pl.col(entity_col).is_in(stale_entities_at_endpoint)
|
|
344
|
+
)
|
|
345
|
+
n_stale = len(stale_entities_at_endpoint)
|
|
346
|
+
if n_stale > 0:
|
|
347
|
+
print(
|
|
348
|
+
f" Filtered {n_stale} stale entities at endpoint "
|
|
349
|
+
f"(inactive >{decline_window_quarters}Q)"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# get final entity list after filters
|
|
353
|
+
valid_entity_list = (
|
|
354
|
+
cumulative_up_to_endpoint.select(entity_col).unique().to_series().to_list()
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
n_valid_at_endpoint = len(valid_entity_list)
|
|
358
|
+
print(
|
|
359
|
+
f" Valid entities (≥{min_total_count} count by {final_quarter_name}): "
|
|
360
|
+
f"{n_valid_at_endpoint}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# step 3: track movement through quarters
|
|
364
|
+
print(f"\n[3/3] Tracking movement through {len(quarter_schedule)} quarters...")
|
|
365
|
+
|
|
366
|
+
movement_records = []
|
|
367
|
+
|
|
368
|
+
for quarter_name, end_date_str in quarter_schedule:
|
|
369
|
+
print(f" [{quarter_name}] Running GLMM on cumulative data...", end=" ")
|
|
370
|
+
|
|
371
|
+
# filter to cumulative data up to this quarter
|
|
372
|
+
end_pl = pl.lit(end_date_str).str.to_date()
|
|
373
|
+
cumulative_df = df.filter(
|
|
374
|
+
(pl.col(timestamp_col) < end_pl)
|
|
375
|
+
& (pl.col(entity_col).is_in(valid_entity_list))
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if len(cumulative_df) < 100:
|
|
379
|
+
print(f"⚠ Insufficient data ({len(cumulative_df)} rows)")
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
# run glmm on this cumulative period
|
|
383
|
+
try:
|
|
384
|
+
period_results, _ = fit_priority_matrix(
|
|
385
|
+
cumulative_df,
|
|
386
|
+
entity_col=entity_col,
|
|
387
|
+
timestamp_col=timestamp_col,
|
|
388
|
+
min_observations=0,
|
|
389
|
+
min_total_count=0,
|
|
390
|
+
decline_window_quarters=0,
|
|
391
|
+
temporal_granularity=temporal_granularity,
|
|
392
|
+
vcp_p=vcp_p,
|
|
393
|
+
fe_p=fe_p,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
print(f"{len(period_results)} entities")
|
|
397
|
+
|
|
398
|
+
# merge with global baseline
|
|
399
|
+
for _, row in period_results.iterrows():
|
|
400
|
+
entity = row[entity_col]
|
|
401
|
+
|
|
402
|
+
if entity in global_baseline:
|
|
403
|
+
movement_records.append(
|
|
404
|
+
{
|
|
405
|
+
"entity": entity,
|
|
406
|
+
"quarter": quarter_name,
|
|
407
|
+
"count_to_date": row["count"],
|
|
408
|
+
"period_x": row["Random_Intercept"],
|
|
409
|
+
"period_y": row["Random_Slope"],
|
|
410
|
+
"period_quadrant": row["quadrant"],
|
|
411
|
+
"global_quadrant": global_baseline[entity][
|
|
412
|
+
"global_quadrant"
|
|
413
|
+
],
|
|
414
|
+
"global_x": global_baseline[entity]["global_x"],
|
|
415
|
+
"global_y": global_baseline[entity]["global_y"],
|
|
416
|
+
"count_total": global_baseline[entity]["count_total"],
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
except Exception as e:
|
|
421
|
+
print(f"✗ Error: {str(e)[:50]}")
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
movement_df = pd.DataFrame(movement_records)
|
|
425
|
+
|
|
426
|
+
if movement_df.empty:
|
|
427
|
+
movement_df = pd.DataFrame(
|
|
428
|
+
columns=[
|
|
429
|
+
"entity",
|
|
430
|
+
"quarter",
|
|
431
|
+
"count_to_date",
|
|
432
|
+
"period_x",
|
|
433
|
+
"period_y",
|
|
434
|
+
"period_quadrant",
|
|
435
|
+
"global_quadrant",
|
|
436
|
+
"global_x",
|
|
437
|
+
"global_y",
|
|
438
|
+
"count_total",
|
|
439
|
+
"x_delta",
|
|
440
|
+
"y_delta",
|
|
441
|
+
"volume_delta",
|
|
442
|
+
]
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# step 4: calculate movement metrics
|
|
446
|
+
print("\n[4/4] Calculating movement metrics...")
|
|
447
|
+
|
|
448
|
+
# add quarter-over-quarter changes
|
|
449
|
+
movement_df = movement_df.sort_values(["entity", "quarter"])
|
|
450
|
+
|
|
451
|
+
# calculate deltas
|
|
452
|
+
movement_df["x_delta"] = movement_df.groupby("entity")["period_x"].diff()
|
|
453
|
+
movement_df["y_delta"] = movement_df.groupby("entity")["period_y"].diff()
|
|
454
|
+
movement_df["volume_delta"] = movement_df.groupby("entity")[
|
|
455
|
+
"count_to_date"
|
|
456
|
+
].diff()
|
|
457
|
+
|
|
458
|
+
# detect quadrant divergence
|
|
459
|
+
movement_df["quadrant_differs"] = (
|
|
460
|
+
movement_df["period_quadrant"] != movement_df["global_quadrant"]
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
print(f"Tracked {len(movement_df)} entity-quarter observations")
|
|
464
|
+
print(f"Entities tracked: {movement_df['entity'].nunique()}")
|
|
465
|
+
print(f"Periods covered: {movement_df['quarter'].nunique()}")
|
|
466
|
+
|
|
467
|
+
divergence_count = movement_df["quadrant_differs"].sum()
|
|
468
|
+
divergence_pct = (
|
|
469
|
+
(divergence_count / len(movement_df) * 100) if len(movement_df) > 0 else 0
|
|
470
|
+
)
|
|
471
|
+
print(f"Quadrant divergences: {divergence_count} ({divergence_pct:.1f}%)")
|
|
472
|
+
|
|
473
|
+
# metadata
|
|
474
|
+
metadata = {
|
|
475
|
+
"entities_tracked": movement_df["entity"].nunique() if not movement_df.empty else 0,
|
|
476
|
+
"quarters_analyzed": len(quarter_schedule),
|
|
477
|
+
"total_observations": len(movement_df),
|
|
478
|
+
"divergence_rate": divergence_pct,
|
|
479
|
+
"temporal_granularity": temporal_granularity,
|
|
480
|
+
"quarter_schedule": quarter_schedule,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return movement_df, metadata
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Transition detection from movement tracking data."""
|
|
2
|
+
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def classify_transition_risk(from_quadrant: str, to_quadrant: str) -> Tuple[str, str]:
|
|
9
|
+
"""
|
|
10
|
+
Classify risk level and type of a quadrant transition.
|
|
11
|
+
|
|
12
|
+
Maps quadrant-to-quadrant transitions to risk levels based on
|
|
13
|
+
trajectory through priority space.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
from_quadrant: Starting quadrant (Q1-Q4)
|
|
17
|
+
to_quadrant: Ending quadrant (Q1-Q4)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Tuple of (risk_level, transition_description)
|
|
21
|
+
- risk_level: "critical", "high", "medium", "low", or "stable"
|
|
22
|
+
- transition_description: Human-readable description
|
|
23
|
+
|
|
24
|
+
Risk Classification:
|
|
25
|
+
**Critical**: Transitions TO Q1 (Crisis)
|
|
26
|
+
- Q2→Q1: Emerging issue became critical
|
|
27
|
+
- Q3→Q1: Low priority escalated to critical
|
|
28
|
+
- Q4→Q1: Persistent issue became critical
|
|
29
|
+
|
|
30
|
+
**High**: Transitions TO Q2 (Investigate)
|
|
31
|
+
- Q3→Q2: Low priority becoming emerging threat
|
|
32
|
+
- Q4→Q2: Persistent issue showing growth
|
|
33
|
+
|
|
34
|
+
**Medium**: Other risk-increasing transitions
|
|
35
|
+
- Q4→Q3: Persistent issue losing volume (watch)
|
|
36
|
+
|
|
37
|
+
**Low**: Risk-decreasing transitions
|
|
38
|
+
- Q1→Q2: Critical issue volume decreasing
|
|
39
|
+
- Q1→Q4: Critical issue growth slowing
|
|
40
|
+
- Q2→Q3: Emerging threat growth slowing
|
|
41
|
+
|
|
42
|
+
**Stable**: All other transitions
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> classify_transition_risk("Q2", "Q1")
|
|
46
|
+
('critical', 'Emerging issue became critical')
|
|
47
|
+
|
|
48
|
+
>>> classify_transition_risk("Q3", "Q2")
|
|
49
|
+
('high', 'Low priority becoming emerging threat')
|
|
50
|
+
|
|
51
|
+
>>> classify_transition_risk("Q1", "Q4")
|
|
52
|
+
('low', 'Critical issue growth slowing')
|
|
53
|
+
"""
|
|
54
|
+
# critical transitions (moving to Q1)
|
|
55
|
+
critical_transitions = {
|
|
56
|
+
("Q2", "Q1"): "Emerging issue became critical",
|
|
57
|
+
("Q3", "Q1"): "Low priority escalated to critical",
|
|
58
|
+
("Q4", "Q1"): "Persistent issue became critical",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# high risk transitions (moving to Q2)
|
|
62
|
+
high_risk_transitions = {
|
|
63
|
+
("Q3", "Q2"): "Low priority becoming emerging threat",
|
|
64
|
+
("Q4", "Q2"): "Persistent issue showing growth",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# medium risk transitions
|
|
68
|
+
medium_risk_transitions = {
|
|
69
|
+
("Q4", "Q3"): "Persistent issue losing volume (watch)"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# low risk transitions (improvements)
|
|
73
|
+
low_risk_transitions = {
|
|
74
|
+
("Q1", "Q2"): "Critical issue volume decreasing",
|
|
75
|
+
("Q1", "Q4"): "Critical issue growth slowing",
|
|
76
|
+
("Q2", "Q3"): "Emerging threat growth slowing",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
transition_key = (from_quadrant, to_quadrant)
|
|
80
|
+
|
|
81
|
+
if transition_key in critical_transitions:
|
|
82
|
+
return "critical", critical_transitions[transition_key]
|
|
83
|
+
elif transition_key in high_risk_transitions:
|
|
84
|
+
return "high", high_risk_transitions[transition_key]
|
|
85
|
+
elif transition_key in medium_risk_transitions:
|
|
86
|
+
return "medium", medium_risk_transitions[transition_key]
|
|
87
|
+
elif transition_key in low_risk_transitions:
|
|
88
|
+
return "low", low_risk_transitions[transition_key]
|
|
89
|
+
else:
|
|
90
|
+
return "stable", f"Transition from {from_quadrant} to {to_quadrant}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_transitions(
|
|
94
|
+
movement_df: pd.DataFrame,
|
|
95
|
+
focus_risk_increasing: bool = True,
|
|
96
|
+
) -> pd.DataFrame:
|
|
97
|
+
"""
|
|
98
|
+
Extract quadrant transitions from cumulative movement tracking data.
|
|
99
|
+
|
|
100
|
+
Detects two types of transitions:
|
|
101
|
+
1. Quadrant-to-quadrant transitions (Q3→Q2, Q2→Q1, etc.)
|
|
102
|
+
2. Within-quadrant dramatic changes (velocity thresholds)
|
|
103
|
+
|
|
104
|
+
Key Innovation:
|
|
105
|
+
- Detects transitions from period_quadrant changes (actual movement)
|
|
106
|
+
- Classifies risk using global_quadrant (stable baseline)
|
|
107
|
+
- No oscillations in risk classification
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
movement_df: DataFrame from track_cumulative_movement()
|
|
111
|
+
Required columns: entity, quarter, period_quadrant,
|
|
112
|
+
global_quadrant, period_x, period_y, x_delta, y_delta
|
|
113
|
+
focus_risk_increasing: Only return risk-increasing transitions (default: True)
|
|
114
|
+
If False, includes all transitions including improvements
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
DataFrame with columns:
|
|
118
|
+
- entity: Entity name
|
|
119
|
+
- transition_quarter: When transition occurred
|
|
120
|
+
- from_quadrant: Previous period quadrant
|
|
121
|
+
- to_quadrant: New period quadrant (with * for within-quadrant)
|
|
122
|
+
- risk_level: "critical", "high", "medium", "low", or "stable"
|
|
123
|
+
- volume_change: X-axis delta
|
|
124
|
+
- growth_change: Y-axis delta
|
|
125
|
+
- transition_type: Human-readable description
|
|
126
|
+
- global_quadrant: Stable baseline quadrant (for reference)
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
>>> transitions = extract_transitions(movement_df)
|
|
130
|
+
>>> critical = transitions[transitions['risk_level'] == 'critical']
|
|
131
|
+
|
|
132
|
+
>>> # include all transitions (not just risk-increasing)
|
|
133
|
+
>>> all_trans = extract_transitions(movement_df, focus_risk_increasing=False)
|
|
134
|
+
"""
|
|
135
|
+
transitions = []
|
|
136
|
+
|
|
137
|
+
# group by entity and sort by quarter
|
|
138
|
+
for entity, entity_data in movement_df.groupby("entity"):
|
|
139
|
+
entity_data = entity_data.sort_values("quarter")
|
|
140
|
+
|
|
141
|
+
# get global quadrant (stable across all periods)
|
|
142
|
+
global_quad = entity_data.iloc[0]["global_quadrant"]
|
|
143
|
+
|
|
144
|
+
# detect period quadrant transitions
|
|
145
|
+
for i in range(len(entity_data) - 1):
|
|
146
|
+
curr_row = entity_data.iloc[i]
|
|
147
|
+
next_row = entity_data.iloc[i + 1]
|
|
148
|
+
|
|
149
|
+
# check if period quadrant changed
|
|
150
|
+
if curr_row["period_quadrant"] != next_row["period_quadrant"]:
|
|
151
|
+
from_quad = curr_row["period_quadrant"]
|
|
152
|
+
to_quad = next_row["period_quadrant"]
|
|
153
|
+
|
|
154
|
+
# classify risk based on transition pattern
|
|
155
|
+
risk_level, transition_type = classify_transition_risk(
|
|
156
|
+
from_quad, to_quad
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# filter if focusing on risk-increasing only
|
|
160
|
+
if focus_risk_increasing and risk_level in ["low", "stable"]:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
transitions.append(
|
|
164
|
+
{
|
|
165
|
+
"entity": entity,
|
|
166
|
+
"transition_quarter": next_row["quarter"],
|
|
167
|
+
"from_quadrant": from_quad,
|
|
168
|
+
"to_quadrant": to_quad,
|
|
169
|
+
"risk_level": risk_level,
|
|
170
|
+
"volume_change": next_row["x_delta"],
|
|
171
|
+
"growth_change": next_row["y_delta"],
|
|
172
|
+
"transition_type": transition_type,
|
|
173
|
+
"global_quadrant": global_quad,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# detect dramatic changes WITHIN same period quadrant
|
|
178
|
+
elif curr_row["period_quadrant"] == next_row["period_quadrant"]:
|
|
179
|
+
growth_change = next_row["y_delta"]
|
|
180
|
+
volume_change = next_row["x_delta"]
|
|
181
|
+
|
|
182
|
+
# flag dramatic acceleration (Y-axis surge > 1.0)
|
|
183
|
+
if growth_change > 1.0:
|
|
184
|
+
transitions.append(
|
|
185
|
+
{
|
|
186
|
+
"entity": entity,
|
|
187
|
+
"transition_quarter": next_row["quarter"],
|
|
188
|
+
"from_quadrant": curr_row["period_quadrant"],
|
|
189
|
+
"to_quadrant": f"{next_row['period_quadrant']}*", # * = within-quadrant
|
|
190
|
+
"risk_level": "critical",
|
|
191
|
+
"volume_change": volume_change,
|
|
192
|
+
"growth_change": growth_change,
|
|
193
|
+
"transition_type": f"Dramatic acceleration within {next_row['period_quadrant']} (+{growth_change:.1f} growth)",
|
|
194
|
+
"global_quadrant": global_quad,
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# flag dramatic volume increase (X-axis surge > 1.0)
|
|
199
|
+
elif volume_change > 1.0:
|
|
200
|
+
transitions.append(
|
|
201
|
+
{
|
|
202
|
+
"entity": entity,
|
|
203
|
+
"transition_quarter": next_row["quarter"],
|
|
204
|
+
"from_quadrant": curr_row["period_quadrant"],
|
|
205
|
+
"to_quadrant": f"{next_row['period_quadrant']}*",
|
|
206
|
+
"risk_level": "high",
|
|
207
|
+
"volume_change": volume_change,
|
|
208
|
+
"growth_change": growth_change,
|
|
209
|
+
"transition_type": f"Major volume surge within {next_row['period_quadrant']} (+{volume_change:.1f} volume)",
|
|
210
|
+
"global_quadrant": global_quad,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return pd.DataFrame(transitions)
|
|
File without changes
|