priorityx 0.2.5__tar.gz → 0.2.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {priorityx-0.2.5 → priorityx-0.2.6}/PKG-INFO +1 -1
- priorityx-0.2.6/docs/README.md +15 -0
- priorityx-0.2.6/docs/output_naming.md +168 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/pyproject.toml +1 -1
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/__init__.py +1 -1
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/glmm.py +36 -1
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/movement.py +206 -56
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/utils/helpers.py +18 -1
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/matrix.py +11 -9
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/timeline.py +34 -7
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/trajectory.py +12 -9
- {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_glmm.py +17 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/uv.lock +1 -1
- {priorityx-0.2.5 → priorityx-0.2.6}/.github/workflows/release.yml +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/.github/workflows/test.yml +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/.gitignore +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/LICENSE +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/Makefile +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/README.md +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/docs/api-reference.md +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/docs/methodology.md +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/docs/priority_classification.md +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/docs/quickstart.md +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/examples/README.md +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/examples/bugs/bugs_monitoring.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/examples/incidents/incident_monitoring.py +2 -2
- {priorityx-0.2.5 → priorityx-0.2.6}/examples/violations/violations_monitoring.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/scripts/version.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/__init__.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/filters.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/quadrants.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/__init__.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/drivers.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/transitions.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/utils/__init__.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/__init__.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_drivers.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_quadrants.py +0 -0
- {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_transitions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: priorityx
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Entity prioritization and escalation detection using GLMM statistical models
|
|
5
5
|
Project-URL: Homepage, https://github.com/okkymabruri/priorityx
|
|
6
6
|
Project-URL: Repository, https://github.com/okkymabruri/priorityx
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Docs overview
|
|
2
|
+
|
|
3
|
+
This folder holds the main reference material for `priorityx`.
|
|
4
|
+
|
|
5
|
+
- `quickstart.md` – short walk‑through: install, load data, run a first
|
|
6
|
+
priority matrix and plot it.
|
|
7
|
+
- `api-reference.md` – function‑by‑function notes for the core modules
|
|
8
|
+
(GLMM, movement, transitions, plots, helpers).
|
|
9
|
+
- `methodology.md` – background on the modelling choices: Poisson GLMM,
|
|
10
|
+
time aggregation, movement tracking, and how we think about risk
|
|
11
|
+
quadrants.
|
|
12
|
+
- `priority_classification.md` – how P1–P4 priorities and spike markers
|
|
13
|
+
are defined and used in timelines.
|
|
14
|
+
- `output_naming.md` – how plot/CSV artifacts are named and where they
|
|
15
|
+
are written on disk (e.g. `results/plot`, `results/csv`).
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
% PriorityX Output Naming
|
|
2
|
+
|
|
3
|
+
This note documents where PriorityX writes plots/CSVs and how filenames
|
|
4
|
+
are constructed. It is meant as a quick reference when wiring outputs
|
|
5
|
+
into EWS pipelines or BI tools.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Directories
|
|
10
|
+
|
|
11
|
+
By default, PriorityX writes artifacts under the current working
|
|
12
|
+
directory, using two main folders:
|
|
13
|
+
|
|
14
|
+
- `results/plot/` – PNG plots
|
|
15
|
+
- `results/csv/` – CSV tables (movement, matrices, transitions, etc.)
|
|
16
|
+
|
|
17
|
+
You can override these via the `plot_dir` / `csv_dir` / `output_dir`
|
|
18
|
+
arguments on the plotting and helper functions.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Priority Matrix
|
|
23
|
+
|
|
24
|
+
Function: `priorityx.viz.matrix.plot_priority_matrix`
|
|
25
|
+
|
|
26
|
+
- Default `plot_dir`: `results/plot`
|
|
27
|
+
- Default `csv_dir`: `results/csv`
|
|
28
|
+
- Filename pattern (plot):
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
priority_matrix-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.png
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- Filename pattern (CSV):
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
priority_matrix-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.csv
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Where:
|
|
41
|
+
|
|
42
|
+
- `<entity_name_slug>` is `entity_name.lower().replace(" ", "_")`
|
|
43
|
+
- `<granularity_suffix>` is:
|
|
44
|
+
- `Q` for quarterly
|
|
45
|
+
- `Y` for yearly
|
|
46
|
+
- `S` for semiannual
|
|
47
|
+
|
|
48
|
+
Example (topics, quarterly):
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
results/plot/priority_matrix-topic-Q-20251126.png
|
|
52
|
+
results/csv/priority_matrix-topic-Q-20251126.csv
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Transition Timelines
|
|
58
|
+
|
|
59
|
+
Function: `priorityx.viz.timeline.plot_transition_timeline`
|
|
60
|
+
|
|
61
|
+
- Default `plot_dir`: `results/plot`
|
|
62
|
+
- Default `csv_dir`: `results/csv`
|
|
63
|
+
- Filename pattern (plot):
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
transition_timeline-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.png
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- Filename pattern (CSV):
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
transition_timeline-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.csv
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Example (FSPs, quarterly):
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
results/plot/transition_timeline-fsp-Q-20251126.png
|
|
79
|
+
results/csv/transition_timeline-fsp-Q-20251126.csv
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Trajectories
|
|
85
|
+
|
|
86
|
+
Function: `priorityx.viz.trajectory.plot_entity_trajectories`
|
|
87
|
+
|
|
88
|
+
- Default `plot_dir`: `results/plot`
|
|
89
|
+
- Default `csv_dir`: `results/csv`
|
|
90
|
+
- Filename pattern (plot):
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
trajectories-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.png
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- When `save_csv=True` (default is `False`), CSV is saved via
|
|
97
|
+
`priorityx.utils.helpers.save_dataframe_to_csv` with artifact name
|
|
98
|
+
`"trajectories"` (see next section).
|
|
99
|
+
|
|
100
|
+
Example (topics, quarterly):
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
results/plot/trajectories-topic-Q-20251126.png
|
|
104
|
+
results/csv/trajectories-topic-Q-20251126.csv # only when save_csv=True
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Helpers: CSV Naming
|
|
110
|
+
|
|
111
|
+
Utility: `priorityx.utils.helpers.save_dataframe_to_csv`
|
|
112
|
+
|
|
113
|
+
This helper centralises CSV naming using `generate_output_path`:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
<output_dir>/<artifact>-<entity_slug>-<granularity_suffix>-<YYYYMMDD>.csv
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Where:
|
|
120
|
+
|
|
121
|
+
- `<output_dir>` defaults to `results/csv`
|
|
122
|
+
- `<artifact>` is a short key such as `movement`, `trajectories`,
|
|
123
|
+
`priority_matrix`
|
|
124
|
+
- `<entity_slug>` is `entity_name.lower().replace(" ", "_")`
|
|
125
|
+
- `<granularity_suffix>` is `Q`, `Y`, or `S` (as above)
|
|
126
|
+
|
|
127
|
+
Examples:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
results/csv/movement-topic-Q-20251126.csv
|
|
131
|
+
results/csv/trajectories-fsp-Q-20251126.csv
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### EWS canonical CSV outputs
|
|
135
|
+
|
|
136
|
+
For the complaints Early Warning System (EWS) pipelines, the canonical
|
|
137
|
+
CSV artifacts per run are:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
movement-*.csv
|
|
141
|
+
priority_matrix-*.csv
|
|
142
|
+
transitions-*.csv
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Trajectory CSVs (`trajectories-*.csv`) are optional and are typically
|
|
146
|
+
only written for selected custom views (for example, a focus list of
|
|
147
|
+
FSPs or products) by explicitly passing `save_csv=True` to
|
|
148
|
+
`plot_entity_trajectories`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Overriding locations
|
|
153
|
+
|
|
154
|
+
All plotting and helper functions accept directories as keyword
|
|
155
|
+
arguments (`plot_dir`, `csv_dir`, `output_dir`). To write outputs into a
|
|
156
|
+
custom EWS workspace, pass explicit paths, for example:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
plot_priority_matrix(
|
|
160
|
+
results_df,
|
|
161
|
+
entity_name="Topic",
|
|
162
|
+
plot_dir="ews/results/plot",
|
|
163
|
+
csv_dir="ews/results/csv",
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
This keeps file naming consistent while letting each project choose
|
|
168
|
+
where artifacts live.
|
|
@@ -69,7 +69,7 @@ def fit_priority_matrix(
|
|
|
69
69
|
min_observations: int = 3,
|
|
70
70
|
min_total_count: int = 0,
|
|
71
71
|
decline_window_quarters: int = 6,
|
|
72
|
-
temporal_granularity: Literal["yearly", "quarterly", "semiannual"] = "yearly",
|
|
72
|
+
temporal_granularity: Literal["yearly", "quarterly", "semiannual", "monthly"] = "yearly",
|
|
73
73
|
vcp_p: float = DEFAULT_VCP_P,
|
|
74
74
|
fe_p: float = DEFAULT_FE_P,
|
|
75
75
|
verbose: bool = False,
|
|
@@ -215,6 +215,25 @@ def fit_priority_matrix(
|
|
|
215
215
|
.sort_values(["year", "semester", entity_col])
|
|
216
216
|
)
|
|
217
217
|
|
|
218
|
+
elif temporal_granularity == "monthly":
|
|
219
|
+
df["year"] = df[timestamp_col].dt.year
|
|
220
|
+
df["month"] = df[timestamp_col].dt.month
|
|
221
|
+
|
|
222
|
+
if count_col:
|
|
223
|
+
df_prepared = (
|
|
224
|
+
df.groupby(["year", "month", entity_col])[count_col]
|
|
225
|
+
.sum()
|
|
226
|
+
.reset_index(name="count")
|
|
227
|
+
.sort_values(["year", "month", entity_col])
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
df_prepared = (
|
|
231
|
+
df.groupby(["year", "month", entity_col])
|
|
232
|
+
.size()
|
|
233
|
+
.reset_index(name="count")
|
|
234
|
+
.sort_values(["year", "month", entity_col])
|
|
235
|
+
)
|
|
236
|
+
|
|
218
237
|
else: # yearly
|
|
219
238
|
df["year"] = df[timestamp_col].dt.year
|
|
220
239
|
|
|
@@ -267,6 +286,16 @@ def fit_priority_matrix(
|
|
|
267
286
|
mean_time = df_prepared["time_continuous"].mean()
|
|
268
287
|
df_prepared["time"] = df_prepared["time_continuous"] - mean_time
|
|
269
288
|
|
|
289
|
+
elif temporal_granularity == "monthly":
|
|
290
|
+
# continuous monthly time: year + (month-1)/12
|
|
291
|
+
df_prepared["time_continuous"] = (
|
|
292
|
+
df_prepared["year"] + (df_prepared["month"] - 1) / 12
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# center for numerical stability
|
|
296
|
+
mean_time = df_prepared["time_continuous"].mean()
|
|
297
|
+
df_prepared["time"] = df_prepared["time_continuous"] - mean_time
|
|
298
|
+
|
|
270
299
|
else: # yearly
|
|
271
300
|
# center year for numerical stability
|
|
272
301
|
mean_year = df_prepared["year"].mean()
|
|
@@ -280,6 +309,8 @@ def fit_priority_matrix(
|
|
|
280
309
|
df_prepared["quarter"] = df_prepared["quarter"].astype("category")
|
|
281
310
|
elif temporal_granularity == "semiannual":
|
|
282
311
|
df_prepared["semester"] = df_prepared["semester"].astype("category")
|
|
312
|
+
elif temporal_granularity == "monthly":
|
|
313
|
+
df_prepared["month"] = df_prepared["month"].astype("category")
|
|
283
314
|
|
|
284
315
|
# ensure positive counts for poisson
|
|
285
316
|
df_prepared = df_prepared[df_prepared["count"] > 0]
|
|
@@ -296,10 +327,14 @@ def fit_priority_matrix(
|
|
|
296
327
|
formula += " + C(quarter)"
|
|
297
328
|
elif temporal_granularity == "semiannual" and n_years >= 2:
|
|
298
329
|
formula += " + C(semester)"
|
|
330
|
+
elif temporal_granularity == "monthly" and n_years >= 2:
|
|
331
|
+
formula += " + C(month)"
|
|
299
332
|
elif temporal_granularity == "quarterly" and n_years == 1:
|
|
300
333
|
print(" Warning: Single-year quarterly data detected, skipping seasonal dummies to avoid multicollinearity")
|
|
301
334
|
elif temporal_granularity == "semiannual" and n_years == 1:
|
|
302
335
|
print(" Warning: Single-year semiannual data detected, skipping seasonal dummies to avoid multicollinearity")
|
|
336
|
+
elif temporal_granularity == "monthly" and n_years == 1:
|
|
337
|
+
print(" Warning: Single-year monthly data detected, skipping seasonal dummies to avoid multicollinearity")
|
|
303
338
|
|
|
304
339
|
# random effects: intercept + slope per entity
|
|
305
340
|
random_formulas = {
|
|
@@ -44,11 +44,32 @@ def _quarter_label(ts: pd.Timestamp) -> str:
|
|
|
44
44
|
return f"{ts.year}-Q{quarter}"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _is_month_start(ts: pd.Timestamp) -> bool:
|
|
48
|
+
"""Check if timestamp is the first day of a calendar month."""
|
|
49
|
+
|
|
50
|
+
return ts.day == 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _next_month_start(ts: pd.Timestamp) -> pd.Timestamp:
|
|
54
|
+
"""Get timestamp for the first day of the next calendar month."""
|
|
55
|
+
|
|
56
|
+
year = ts.year
|
|
57
|
+
month = ts.month
|
|
58
|
+
if month == 12:
|
|
59
|
+
return pd.Timestamp(year=year + 1, month=1, day=1)
|
|
60
|
+
return pd.Timestamp(year=year, month=month + 1, day=1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _month_label(ts: pd.Timestamp) -> str:
|
|
64
|
+
"""Format timestamp into 'YYYY-MM' label."""
|
|
65
|
+
|
|
66
|
+
return ts.strftime("%Y-%m")
|
|
67
|
+
|
|
68
|
+
|
|
47
69
|
def _build_quarter_schedule_from_range(
|
|
48
70
|
start_date: str, end_date: str
|
|
49
71
|
) -> list[tuple[str, str]]:
|
|
50
|
-
"""
|
|
51
|
-
Build quarter schedule from date range.
|
|
72
|
+
"""Build quarter schedule from date range.
|
|
52
73
|
|
|
53
74
|
Args:
|
|
54
75
|
start_date: Start date (YYYY-MM-DD), must be quarter boundary
|
|
@@ -85,6 +106,44 @@ def _build_quarter_schedule_from_range(
|
|
|
85
106
|
return schedule
|
|
86
107
|
|
|
87
108
|
|
|
109
|
+
def _build_month_schedule_from_range(
|
|
110
|
+
start_date: str, end_date: str
|
|
111
|
+
) -> list[tuple[str, str]]:
|
|
112
|
+
"""Build month schedule from date range.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
start_date: Start date (YYYY-MM-DD), must be month boundary (day=1)
|
|
116
|
+
end_date: End date (YYYY-MM-DD), must be month boundary (day=1)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of (label, exclusive_end_date) tuples
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: If dates are not month boundaries or invalid
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
start_ts = pd.Timestamp(start_date)
|
|
126
|
+
end_ts = pd.Timestamp(end_date)
|
|
127
|
+
|
|
128
|
+
if start_ts >= end_ts:
|
|
129
|
+
raise ValueError("Start date must be earlier than end date")
|
|
130
|
+
|
|
131
|
+
if not _is_month_start(start_ts) or not _is_month_start(end_ts):
|
|
132
|
+
raise ValueError("Dates must be month boundaries (YYYY-MM-01)")
|
|
133
|
+
|
|
134
|
+
schedule: list[tuple[str, str]] = []
|
|
135
|
+
current = start_ts
|
|
136
|
+
while current < end_ts:
|
|
137
|
+
next_start = _next_month_start(current)
|
|
138
|
+
if next_start > end_ts:
|
|
139
|
+
next_start = end_ts
|
|
140
|
+
|
|
141
|
+
schedule.append((_month_label(current), next_start.strftime("%Y-%m-%d")))
|
|
142
|
+
current = next_start
|
|
143
|
+
|
|
144
|
+
return schedule
|
|
145
|
+
|
|
146
|
+
|
|
88
147
|
def _build_default_quarter_schedule(
|
|
89
148
|
df: pd.DataFrame, timestamp_col: str
|
|
90
149
|
) -> list[tuple[str, str]]:
|
|
@@ -120,67 +179,116 @@ def _build_default_quarter_schedule(
|
|
|
120
179
|
)
|
|
121
180
|
|
|
122
181
|
|
|
123
|
-
def
|
|
182
|
+
def _build_default_month_schedule(
|
|
183
|
+
df: pd.DataFrame, timestamp_col: str
|
|
184
|
+
) -> list[tuple[str, str]]:
|
|
185
|
+
"""Create default month schedule spanning the dataset."""
|
|
186
|
+
|
|
187
|
+
if timestamp_col not in df.columns or len(df) == 0:
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
df = df.copy()
|
|
191
|
+
df[timestamp_col] = pd.to_datetime(df[timestamp_col])
|
|
192
|
+
|
|
193
|
+
min_date = df[timestamp_col].min()
|
|
194
|
+
max_date = df[timestamp_col].max()
|
|
195
|
+
|
|
196
|
+
if pd.isna(min_date) or pd.isna(max_date):
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
start_ts = min_date.to_period("M").to_timestamp(how="start")
|
|
200
|
+
max_month_start = max_date.to_period("M").to_timestamp(how="start")
|
|
201
|
+
end_ts = _next_month_start(max_month_start)
|
|
202
|
+
|
|
203
|
+
return _build_month_schedule_from_range(
|
|
204
|
+
start_ts.strftime("%Y-%m-%d"), end_ts.strftime("%Y-%m-%d")
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def normalize_period_schedule(
|
|
124
209
|
quarters: Optional[Sequence[Union[tuple[str, str], str]]],
|
|
125
210
|
df: pd.DataFrame,
|
|
126
211
|
timestamp_col: str,
|
|
212
|
+
temporal_granularity: str = "quarterly",
|
|
127
213
|
) -> list[tuple[str, str]]:
|
|
128
|
-
"""
|
|
129
|
-
Normalize quarter schedule to standard format.
|
|
214
|
+
"""Normalize period schedule to standard (label, cutoff_date) format.
|
|
130
215
|
|
|
131
|
-
|
|
216
|
+
For quarterly granularity:
|
|
132
217
|
1. Explicit schedule: [("2024-Q2", "2024-07-01"), ...]
|
|
133
|
-
2. Date range: ["2024-01-01", "2025-10-01"]
|
|
218
|
+
2. Date range: ["2024-01-01", "2025-10-01"]
|
|
134
219
|
3. None: Auto-detect from data
|
|
135
220
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
timestamp_col: Column name for timestamp
|
|
221
|
+
For monthly granularity, the same patterns apply but labels are
|
|
222
|
+
"YYYY-MM" and dates must be month boundaries.
|
|
223
|
+
"""
|
|
140
224
|
|
|
141
|
-
|
|
142
|
-
|
|
225
|
+
if temporal_granularity == "monthly":
|
|
226
|
+
if quarters is None:
|
|
227
|
+
return _build_default_month_schedule(df, timestamp_col)
|
|
143
228
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
>>> normalize_quarter_schedule(
|
|
147
|
-
... [("2024-Q1", "2024-04-01"), ("2024-Q2", "2024-07-01")],
|
|
148
|
-
... df, "date"
|
|
149
|
-
... )
|
|
229
|
+
if len(quarters) == 0:
|
|
230
|
+
return []
|
|
150
231
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
... )
|
|
232
|
+
# date range format: ["2024-01-01", "2025-10-01"]
|
|
233
|
+
if len(quarters) == 2 and all(isinstance(q, str) for q in quarters):
|
|
234
|
+
return _build_month_schedule_from_range(quarters[0], quarters[1])
|
|
155
235
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
236
|
+
normalized_schedule: list[tuple[str, str]] = []
|
|
237
|
+
for entry in quarters:
|
|
238
|
+
if not isinstance(entry, tuple) or len(entry) != 2:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"quarters must be a list of (label, cutoff_date) tuples "
|
|
241
|
+
"or two date boundary strings",
|
|
242
|
+
)
|
|
243
|
+
label, cutoff = entry
|
|
244
|
+
normalized_schedule.append((label, cutoff))
|
|
245
|
+
|
|
246
|
+
return normalized_schedule
|
|
247
|
+
|
|
248
|
+
# default: quarterly schedule
|
|
159
249
|
if quarters is None:
|
|
160
250
|
return _build_default_quarter_schedule(df, timestamp_col)
|
|
161
251
|
|
|
162
252
|
if len(quarters) == 0:
|
|
163
253
|
return []
|
|
164
254
|
|
|
165
|
-
# check first element type
|
|
166
|
-
quarters[0]
|
|
167
|
-
|
|
168
255
|
# date range format: ["2024-01-01", "2025-10-01"]
|
|
169
256
|
if len(quarters) == 2 and all(isinstance(q, str) for q in quarters):
|
|
170
257
|
return _build_quarter_schedule_from_range(quarters[0], quarters[1])
|
|
171
258
|
|
|
172
259
|
# explicit schedule format: [("2024-Q2", "2024-07-01"), ...]
|
|
173
|
-
|
|
260
|
+
normalized_schedule2: list[tuple[str, str]] = []
|
|
174
261
|
for entry in quarters:
|
|
175
262
|
if not isinstance(entry, tuple) or len(entry) != 2:
|
|
176
263
|
raise ValueError(
|
|
177
264
|
"quarters must be a list of (label, cutoff_date) tuples "
|
|
178
|
-
"or two quarter boundary strings"
|
|
265
|
+
"or two quarter boundary strings",
|
|
179
266
|
)
|
|
180
267
|
label, cutoff = entry
|
|
181
|
-
|
|
268
|
+
normalized_schedule2.append((label, cutoff))
|
|
269
|
+
|
|
270
|
+
return normalized_schedule2
|
|
182
271
|
|
|
183
|
-
|
|
272
|
+
|
|
273
|
+
def normalize_quarter_schedule(
|
|
274
|
+
quarters: Optional[Sequence[Union[tuple[str, str], str]]],
|
|
275
|
+
df: pd.DataFrame,
|
|
276
|
+
timestamp_col: str,
|
|
277
|
+
temporal_granularity: str = "quarterly",
|
|
278
|
+
) -> list[tuple[str, str]]:
|
|
279
|
+
"""Backward-compatible alias for :func:`normalize_period_schedule`.
|
|
280
|
+
|
|
281
|
+
Historically this helper only handled quarterly schedules; it now
|
|
282
|
+
delegates to ``normalize_period_schedule`` which supports both
|
|
283
|
+
quarterly and monthly granularities.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
return normalize_period_schedule(
|
|
287
|
+
quarters,
|
|
288
|
+
df,
|
|
289
|
+
timestamp_col,
|
|
290
|
+
temporal_granularity=temporal_granularity,
|
|
291
|
+
)
|
|
184
292
|
|
|
185
293
|
|
|
186
294
|
def track_cumulative_movement(
|
|
@@ -208,7 +316,7 @@ def track_cumulative_movement(
|
|
|
208
316
|
df: Input pandas DataFrame
|
|
209
317
|
entity_col: Column name for entity identifier
|
|
210
318
|
timestamp_col: Column name for timestamp (datetime type)
|
|
211
|
-
quarters:
|
|
319
|
+
quarters: Period specification (see normalize_period_schedule)
|
|
212
320
|
min_total_count: Minimum total count for inclusion (default: 20)
|
|
213
321
|
decline_window_quarters: Max quarters after last observation (default: 6)
|
|
214
322
|
temporal_granularity: Time granularity for GLMM ('quarterly', 'yearly', 'semiannual')
|
|
@@ -217,10 +325,12 @@ def track_cumulative_movement(
|
|
|
217
325
|
|
|
218
326
|
Returns:
|
|
219
327
|
Tuple of (movement_df, metadata_dict)
|
|
220
|
-
- movement_df:
|
|
328
|
+
- movement_df: Period-by-period tracking with columns:
|
|
221
329
|
[entity, quarter, period_count, cumulative_count, period_x, period_y,
|
|
222
330
|
period_quadrant, global_quadrant, global_x, global_y,
|
|
223
331
|
count_total, x_delta, y_delta, quadrant_differs]
|
|
332
|
+
(the ``quarter`` column serves as the canonical period axis
|
|
333
|
+
for both quarterly and monthly granularities)
|
|
224
334
|
- metadata_dict: Tracking statistics and configuration
|
|
225
335
|
|
|
226
336
|
Examples:
|
|
@@ -239,15 +349,22 @@ def track_cumulative_movement(
|
|
|
239
349
|
df = df.copy()
|
|
240
350
|
df[timestamp_col] = pd.to_datetime(df[timestamp_col])
|
|
241
351
|
|
|
242
|
-
# normalize quarter schedule
|
|
243
|
-
|
|
352
|
+
# normalize quarter/month schedule (generic period schedule)
|
|
353
|
+
period_schedule = normalize_period_schedule(
|
|
354
|
+
quarters,
|
|
355
|
+
df,
|
|
356
|
+
timestamp_col,
|
|
357
|
+
temporal_granularity=temporal_granularity,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
period_col = "quarter"
|
|
244
361
|
|
|
245
|
-
if len(
|
|
246
|
-
print("Warning: No
|
|
362
|
+
if len(period_schedule) == 0:
|
|
363
|
+
print("Warning: No period boundaries available for cumulative tracking")
|
|
247
364
|
empty_df = pd.DataFrame(
|
|
248
365
|
columns=[
|
|
249
366
|
"entity",
|
|
250
|
-
|
|
367
|
+
period_col,
|
|
251
368
|
"period_count",
|
|
252
369
|
"cumulative_count",
|
|
253
370
|
"period_x",
|
|
@@ -308,8 +425,8 @@ def track_cumulative_movement(
|
|
|
308
425
|
# step 2: determine valid entities at analysis endpoint
|
|
309
426
|
print("\n[2/3] Determining valid entities at analysis endpoint...")
|
|
310
427
|
|
|
311
|
-
# get endpoint (last
|
|
312
|
-
final_quarter_name, final_quarter_date =
|
|
428
|
+
# get endpoint (last period in schedule)
|
|
429
|
+
final_quarter_name, final_quarter_date = period_schedule[-1]
|
|
313
430
|
print(f"Analysis endpoint: {final_quarter_name} ({final_quarter_date})")
|
|
314
431
|
|
|
315
432
|
# calculate valid entities based on cumulative totals up to endpoint
|
|
@@ -360,12 +477,12 @@ def track_cumulative_movement(
|
|
|
360
477
|
f"{n_valid_at_endpoint}"
|
|
361
478
|
)
|
|
362
479
|
|
|
363
|
-
# step 3: track movement through
|
|
364
|
-
print(f"\n[3/3] Tracking movement through {len(
|
|
480
|
+
# step 3: track movement through periods
|
|
481
|
+
print(f"\n[3/3] Tracking movement through {len(period_schedule)} periods...")
|
|
365
482
|
|
|
366
483
|
movement_records = []
|
|
367
484
|
|
|
368
|
-
for quarter_name, end_date_str in
|
|
485
|
+
for quarter_name, end_date_str in period_schedule:
|
|
369
486
|
print(f" [{quarter_name}] Running GLMM on cumulative data...", end=" ")
|
|
370
487
|
|
|
371
488
|
# filter to cumulative data up to this quarter
|
|
@@ -402,7 +519,7 @@ def track_cumulative_movement(
|
|
|
402
519
|
movement_records.append(
|
|
403
520
|
{
|
|
404
521
|
"entity": entity,
|
|
405
|
-
|
|
522
|
+
period_col: quarter_name,
|
|
406
523
|
"cumulative_count": row["count"],
|
|
407
524
|
"period_x": row["Random_Intercept"],
|
|
408
525
|
"period_y": row["Random_Slope"],
|
|
@@ -426,7 +543,7 @@ def track_cumulative_movement(
|
|
|
426
543
|
movement_df = pd.DataFrame(
|
|
427
544
|
columns=[
|
|
428
545
|
"entity",
|
|
429
|
-
|
|
546
|
+
period_col,
|
|
430
547
|
"period_count",
|
|
431
548
|
"cumulative_count",
|
|
432
549
|
"period_x",
|
|
@@ -445,8 +562,8 @@ def track_cumulative_movement(
|
|
|
445
562
|
# step 4: calculate movement metrics
|
|
446
563
|
print("\n[4/4] Calculating movement metrics...")
|
|
447
564
|
|
|
448
|
-
# add
|
|
449
|
-
movement_df = movement_df.sort_values(["entity",
|
|
565
|
+
# add period-over-period changes
|
|
566
|
+
movement_df = movement_df.sort_values(["entity", period_col])
|
|
450
567
|
|
|
451
568
|
# calculate deltas
|
|
452
569
|
movement_df["x_delta"] = movement_df.groupby("entity")["period_x"].diff()
|
|
@@ -465,7 +582,7 @@ def track_cumulative_movement(
|
|
|
465
582
|
# reorder columns for readability
|
|
466
583
|
cols = [
|
|
467
584
|
"entity",
|
|
468
|
-
|
|
585
|
+
period_col,
|
|
469
586
|
"period_count",
|
|
470
587
|
"cumulative_count",
|
|
471
588
|
"period_x",
|
|
@@ -485,9 +602,9 @@ def track_cumulative_movement(
|
|
|
485
602
|
+ [c for c in movement_df.columns if c not in cols]
|
|
486
603
|
]
|
|
487
604
|
|
|
488
|
-
print(f"Tracked {len(movement_df)} entity-
|
|
605
|
+
print(f"Tracked {len(movement_df)} entity-period observations")
|
|
489
606
|
print(f"Entities tracked: {movement_df['entity'].nunique()}")
|
|
490
|
-
print(f"Periods covered: {movement_df[
|
|
607
|
+
print(f"Periods covered: {movement_df[period_col].nunique()}")
|
|
491
608
|
|
|
492
609
|
divergence_count = movement_df["quadrant_differs"].sum()
|
|
493
610
|
divergence_pct = (
|
|
@@ -500,11 +617,11 @@ def track_cumulative_movement(
|
|
|
500
617
|
"entities_tracked": movement_df["entity"].nunique()
|
|
501
618
|
if not movement_df.empty
|
|
502
619
|
else 0,
|
|
503
|
-
"quarters_analyzed": len(
|
|
620
|
+
"quarters_analyzed": len(period_schedule),
|
|
504
621
|
"total_observations": len(movement_df),
|
|
505
622
|
"divergence_rate": divergence_pct,
|
|
506
623
|
"temporal_granularity": temporal_granularity,
|
|
507
|
-
"quarter_schedule":
|
|
624
|
+
"quarter_schedule": period_schedule,
|
|
508
625
|
}
|
|
509
626
|
|
|
510
627
|
return movement_df, metadata
|
|
@@ -531,9 +648,36 @@ def load_or_track_movement(
|
|
|
531
648
|
|
|
532
649
|
csv_path: str | None = None
|
|
533
650
|
if use_cache:
|
|
534
|
-
csv_path = latest_artifact_csv(
|
|
651
|
+
csv_path = latest_artifact_csv(
|
|
652
|
+
"movement",
|
|
653
|
+
entity_name,
|
|
654
|
+
output_dir,
|
|
655
|
+
temporal_granularity=temporal_granularity,
|
|
656
|
+
)
|
|
535
657
|
if csv_path:
|
|
536
658
|
movement_df = pd.read_csv(csv_path)
|
|
659
|
+
if temporal_granularity == "monthly":
|
|
660
|
+
# New-style monthly CSVs use a friendlier ``month``
|
|
661
|
+
# column on disk; normalize to ``quarter`` in-memory
|
|
662
|
+
# for compatibility with existing code.
|
|
663
|
+
if (
|
|
664
|
+
"month" in movement_df.columns
|
|
665
|
+
and "quarter" not in movement_df.columns
|
|
666
|
+
):
|
|
667
|
+
movement_df = movement_df.rename(columns={"month": "quarter"})
|
|
668
|
+
|
|
669
|
+
# Legacy monthly CSVs were written with a ``quarter``
|
|
670
|
+
# column. Treat that as ``month`` for users by
|
|
671
|
+
# rewriting the CSV header to ``month`` while keeping
|
|
672
|
+
# the in-memory schema unchanged.
|
|
673
|
+
elif (
|
|
674
|
+
"quarter" in movement_df.columns
|
|
675
|
+
and "month" not in movement_df.columns
|
|
676
|
+
):
|
|
677
|
+
month_df = movement_df.rename(columns={"quarter": "month"})
|
|
678
|
+
month_df.to_csv(csv_path, index=False)
|
|
679
|
+
movement_df = month_df.rename(columns={"month": "quarter"})
|
|
680
|
+
|
|
537
681
|
return movement_df, csv_path
|
|
538
682
|
|
|
539
683
|
movement_df, _meta = track_cumulative_movement(
|
|
@@ -545,8 +689,14 @@ def load_or_track_movement(
|
|
|
545
689
|
temporal_granularity=temporal_granularity,
|
|
546
690
|
)
|
|
547
691
|
|
|
692
|
+
# For monthly outputs, persist a friendlier "month" label on disk
|
|
693
|
+
# while keeping the in-memory DataFrame schema unchanged.
|
|
694
|
+
movement_to_save = movement_df
|
|
695
|
+
if temporal_granularity == "monthly" and "quarter" in movement_df.columns:
|
|
696
|
+
movement_to_save = movement_df.rename(columns={"quarter": "month"})
|
|
697
|
+
|
|
548
698
|
csv_path = save_dataframe_to_csv(
|
|
549
|
-
|
|
699
|
+
movement_to_save,
|
|
550
700
|
artifact="movement",
|
|
551
701
|
entity_name=entity_name,
|
|
552
702
|
temporal_granularity=temporal_granularity,
|
|
@@ -260,6 +260,7 @@ def generate_output_path(
|
|
|
260
260
|
"quarterly": "Q",
|
|
261
261
|
"yearly": "Y",
|
|
262
262
|
"semiannual": "S",
|
|
263
|
+
"monthly": "M",
|
|
263
264
|
}.get(temporal_granularity, "Q")
|
|
264
265
|
|
|
265
266
|
timestamp = datetime.now().strftime("%Y%m%d")
|
|
@@ -305,15 +306,31 @@ def latest_artifact_csv(
|
|
|
305
306
|
artifact: str,
|
|
306
307
|
entity_name: str,
|
|
307
308
|
output_dir: str = "results/csv",
|
|
309
|
+
temporal_granularity: str | None = None,
|
|
308
310
|
) -> str | None:
|
|
309
311
|
"""Find the latest CSV for a given artifact/entity pair.
|
|
310
312
|
|
|
311
313
|
Assumes filenames follow ``generate_output_path`` convention:
|
|
312
314
|
``<output_dir>/<artifact>-<entity_slug>-<granularity>-<YYYYMMDD>.csv``.
|
|
315
|
+
|
|
316
|
+
If ``temporal_granularity`` is provided, restricts matches to that
|
|
317
|
+
granularity suffix (e.g. "Q", "M"); otherwise returns the latest
|
|
318
|
+
CSV across all granularities.
|
|
313
319
|
"""
|
|
314
320
|
|
|
315
321
|
slug = entity_name.lower().replace(" ", "_")
|
|
316
322
|
directory = Path(output_dir)
|
|
317
|
-
|
|
323
|
+
|
|
324
|
+
if temporal_granularity is not None:
|
|
325
|
+
granularity_suffix = {
|
|
326
|
+
"quarterly": "Q",
|
|
327
|
+
"yearly": "Y",
|
|
328
|
+
"semiannual": "S",
|
|
329
|
+
"monthly": "M",
|
|
330
|
+
}.get(temporal_granularity, "Q")
|
|
331
|
+
pattern = f"{artifact}-{slug}-{granularity_suffix}-*.csv"
|
|
332
|
+
else:
|
|
333
|
+
pattern = f"{artifact}-{slug}-*.csv"
|
|
334
|
+
|
|
318
335
|
matches = sorted(directory.glob(pattern))
|
|
319
336
|
return str(matches[-1]) if matches else None
|
|
@@ -8,6 +8,8 @@ import matplotlib.ticker as ticker
|
|
|
8
8
|
import pandas as pd
|
|
9
9
|
from matplotlib.lines import Line2D
|
|
10
10
|
|
|
11
|
+
from priorityx.core.quadrants import get_quadrant_label
|
|
12
|
+
|
|
11
13
|
# try to import adjustText for label positioning
|
|
12
14
|
try:
|
|
13
15
|
from adjustText import adjust_text
|
|
@@ -65,7 +67,7 @@ def plot_priority_matrix(
|
|
|
65
67
|
save_csv: Save data to CSV
|
|
66
68
|
plot_dir: Output directory for plot files
|
|
67
69
|
csv_dir: Output directory for CSV files
|
|
68
|
-
temporal_granularity: Time granularity ('quarterly', 'yearly', 'semiannual')
|
|
70
|
+
temporal_granularity: Time granularity ('quarterly', 'yearly', 'semiannual', 'monthly')
|
|
69
71
|
close_fig: Close the figure before returning (set True if you see duplicate inline renders)
|
|
70
72
|
|
|
71
73
|
Returns:
|
|
@@ -144,16 +146,14 @@ def plot_priority_matrix(
|
|
|
144
146
|
|
|
145
147
|
# define colors for each quadrant (tab20 - distinct hues)
|
|
146
148
|
colors = {
|
|
147
|
-
"Q1": "#d62728", #
|
|
148
|
-
"Q2": "#ff7f0e", #
|
|
149
|
-
"Q4": "#1f77b4", #
|
|
150
|
-
"Q3": "#2ca02c", #
|
|
149
|
+
"Q1": "#d62728", # top-right quadrant
|
|
150
|
+
"Q2": "#ff7f0e", # top-left quadrant
|
|
151
|
+
"Q4": "#1f77b4", # bottom-right quadrant
|
|
152
|
+
"Q3": "#2ca02c", # bottom-left quadrant
|
|
151
153
|
}
|
|
152
154
|
quadrant_display = {
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"Q3": "Q3 (Monitor)",
|
|
156
|
-
"Q4": "Q4 (Low Priority)",
|
|
155
|
+
q: get_quadrant_label(q, x_label="Volume", y_label="Growth")
|
|
156
|
+
for q in ["Q1", "Q2", "Q3", "Q4"]
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
# plot all points
|
|
@@ -316,6 +316,7 @@ def plot_priority_matrix(
|
|
|
316
316
|
"quarterly": "Q",
|
|
317
317
|
"yearly": "Y",
|
|
318
318
|
"semiannual": "S",
|
|
319
|
+
"monthly": "M",
|
|
319
320
|
}.get(temporal_granularity, "Q")
|
|
320
321
|
save_path = f"{plot_dir}/priority_matrix-{entity_name.lower()}-{granularity_suffix}-{timestamp}.png"
|
|
321
322
|
|
|
@@ -333,6 +334,7 @@ def plot_priority_matrix(
|
|
|
333
334
|
"quarterly": "Q",
|
|
334
335
|
"yearly": "Y",
|
|
335
336
|
"semiannual": "S",
|
|
337
|
+
"monthly": "M",
|
|
336
338
|
}.get(temporal_granularity, "Q")
|
|
337
339
|
csv_path = f"{csv_dir}/priority_matrix-{entity_name.lower()}-{granularity_suffix}-{timestamp}.csv"
|
|
338
340
|
|
|
@@ -24,7 +24,7 @@ def plot_transition_timeline(
|
|
|
24
24
|
max_entities: Optional[int] = 20,
|
|
25
25
|
figsize: Tuple[int, int] = (16, 12),
|
|
26
26
|
title: Optional[str] = None,
|
|
27
|
-
x_axis_granularity: Literal["quarterly", "semiannual", "yearly"] = "quarterly",
|
|
27
|
+
x_axis_granularity: Literal["quarterly", "semiannual", "yearly", "monthly"] = "quarterly",
|
|
28
28
|
sort_by_risk_first: bool = True,
|
|
29
29
|
entity_name: str = "Entity",
|
|
30
30
|
show_all_periods: bool = False,
|
|
@@ -106,7 +106,7 @@ def plot_transition_timeline(
|
|
|
106
106
|
risk_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
107
107
|
df["risk_order"] = df["risk_level"].map(risk_order)
|
|
108
108
|
|
|
109
|
-
# aggregate
|
|
109
|
+
# aggregate periods based on granularity
|
|
110
110
|
if x_axis_granularity == "semiannual":
|
|
111
111
|
|
|
112
112
|
def quarter_to_semester(q_str):
|
|
@@ -125,6 +125,9 @@ def plot_transition_timeline(
|
|
|
125
125
|
return q_str
|
|
126
126
|
|
|
127
127
|
df["period"] = df["transition_quarter"].apply(quarter_to_year)
|
|
128
|
+
elif x_axis_granularity == "monthly":
|
|
129
|
+
# use transition_quarter as-is (assumed monthly label like YYYY-MM)
|
|
130
|
+
df["period"] = df["transition_quarter"]
|
|
128
131
|
else: # quarterly
|
|
129
132
|
df["period"] = df["transition_quarter"]
|
|
130
133
|
|
|
@@ -267,9 +270,14 @@ def plot_transition_timeline(
|
|
|
267
270
|
ax.scatter(x_pos, y_pos, s=100, c=color, alpha=0.8, zorder=2)
|
|
268
271
|
|
|
269
272
|
# add transition label with spike indicator
|
|
270
|
-
|
|
273
|
+
to_quad = str(transition["to_quadrant"])
|
|
274
|
+
label = f"{transition['from_quadrant']}→{to_quad}"
|
|
275
|
+
|
|
276
|
+
# avoid duplicating spike markers when to_quadrant already
|
|
277
|
+
# contains *X / *Y / *XY
|
|
278
|
+
has_inline_spike = "*" in to_quad
|
|
271
279
|
|
|
272
|
-
if spike_axis:
|
|
280
|
+
if spike_axis and not has_inline_spike:
|
|
273
281
|
# add superscript indicator
|
|
274
282
|
if spike_axis == "Y":
|
|
275
283
|
label += "*$^Y$"
|
|
@@ -313,7 +321,9 @@ def plot_transition_timeline(
|
|
|
313
321
|
period_labels.append(f"H{semester} {year}")
|
|
314
322
|
else:
|
|
315
323
|
period_labels.append(p)
|
|
316
|
-
|
|
324
|
+
elif x_axis_granularity == "yearly":
|
|
325
|
+
period_labels = periods
|
|
326
|
+
else: # monthly
|
|
317
327
|
period_labels = periods
|
|
318
328
|
|
|
319
329
|
ax.set_xticks(range(len(periods)))
|
|
@@ -365,7 +375,12 @@ def plot_transition_timeline(
|
|
|
365
375
|
)
|
|
366
376
|
|
|
367
377
|
# set labels
|
|
368
|
-
xlabel_map = {
|
|
378
|
+
xlabel_map = {
|
|
379
|
+
"quarterly": "Quarter",
|
|
380
|
+
"semiannual": "Semester",
|
|
381
|
+
"yearly": "Year",
|
|
382
|
+
"monthly": "Month",
|
|
383
|
+
}
|
|
369
384
|
axis_fontsize = 15
|
|
370
385
|
ax.set_xlabel(xlabel_map.get(x_axis_granularity, "Period"), fontsize=axis_fontsize)
|
|
371
386
|
ax.set_ylabel(entity_name, fontsize=axis_fontsize)
|
|
@@ -389,6 +404,7 @@ def plot_transition_timeline(
|
|
|
389
404
|
"quarterly": "Q",
|
|
390
405
|
"yearly": "Y",
|
|
391
406
|
"semiannual": "S",
|
|
407
|
+
"monthly": "M",
|
|
392
408
|
}.get(temporal_granularity, "Q")
|
|
393
409
|
plot_path = f"{plot_dir}/transition_timeline-{entity_name.lower()}-{granularity_suffix}-{timestamp}.png"
|
|
394
410
|
plt.savefig(plot_path, dpi=300, bbox_inches="tight", format="png")
|
|
@@ -405,9 +421,20 @@ def plot_transition_timeline(
|
|
|
405
421
|
"quarterly": "Q",
|
|
406
422
|
"yearly": "Y",
|
|
407
423
|
"semiannual": "S",
|
|
424
|
+
"monthly": "M",
|
|
408
425
|
}.get(temporal_granularity, "Q")
|
|
409
426
|
csv_path = f"{csv_dir}/transitions-{entity_name.lower()}-{granularity_suffix}-{timestamp}.csv"
|
|
410
|
-
|
|
427
|
+
|
|
428
|
+
df_to_save = df
|
|
429
|
+
# For monthly outputs, expose a clearer ``transition_month`` label
|
|
430
|
+
# in the CSV while keeping the internal transition_quarter column
|
|
431
|
+
# for code that relies on it.
|
|
432
|
+
if temporal_granularity == "monthly" and "transition_quarter" in df.columns:
|
|
433
|
+
df_to_save = df.copy().rename(
|
|
434
|
+
columns={"transition_quarter": "transition_month"}
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
df_to_save.to_csv(csv_path, index=False)
|
|
411
438
|
print(f"Transitions CSV saved: {csv_path}")
|
|
412
439
|
|
|
413
440
|
if close_fig:
|
|
@@ -6,6 +6,7 @@ import warnings
|
|
|
6
6
|
import matplotlib.pyplot as plt
|
|
7
7
|
import pandas as pd
|
|
8
8
|
|
|
9
|
+
from priorityx.core.quadrants import get_quadrant_label
|
|
9
10
|
from priorityx.utils.helpers import save_dataframe_to_csv
|
|
10
11
|
|
|
11
12
|
# suppress noisy FancyArrowPatch fallback warnings from annotate/adjustments
|
|
@@ -120,16 +121,14 @@ def plot_entity_trajectories(
|
|
|
120
121
|
|
|
121
122
|
# define colors for quadrants (tab20 - distinct hues)
|
|
122
123
|
colors = {
|
|
123
|
-
"Q1": "#d62728", #
|
|
124
|
-
"Q2": "#ff7f0e", #
|
|
125
|
-
"Q4": "#1f77b4", #
|
|
126
|
-
"Q3": "#2ca02c", #
|
|
124
|
+
"Q1": "#d62728", # top-right quadrant
|
|
125
|
+
"Q2": "#ff7f0e", # top-left quadrant
|
|
126
|
+
"Q4": "#1f77b4", # bottom-right quadrant
|
|
127
|
+
"Q3": "#2ca02c", # bottom-left quadrant
|
|
127
128
|
}
|
|
128
129
|
quadrant_display = {
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"Q3": "Q3 (Monitor)",
|
|
132
|
-
"Q4": "Q4 (Low Priority)",
|
|
130
|
+
q: get_quadrant_label(q, x_label="Volume", y_label="Growth")
|
|
131
|
+
for q in ["Q1", "Q2", "Q3", "Q4"]
|
|
133
132
|
}
|
|
134
133
|
|
|
135
134
|
# track usage counts per color to slightly vary hue if needed
|
|
@@ -308,15 +307,19 @@ def plot_entity_trajectories(
|
|
|
308
307
|
"quarterly": "Q",
|
|
309
308
|
"yearly": "Y",
|
|
310
309
|
"semiannual": "S",
|
|
310
|
+
"monthly": "M",
|
|
311
311
|
}.get(temporal_granularity, "Q")
|
|
312
312
|
plot_path = f"{plot_dir}/trajectories-{entity_name.lower()}-{granularity_suffix}-{timestamp}.png"
|
|
313
313
|
plt.savefig(plot_path, dpi=300, bbox_inches="tight", format="png")
|
|
314
314
|
print(f"Entity trajectory plot saved: {plot_path}")
|
|
315
315
|
|
|
316
316
|
if save_csv:
|
|
317
|
+
# Only save the trajectories actually plotted (df_plot), not the
|
|
318
|
+
# full movement_df. The full movement data remains available in
|
|
319
|
+
# the movement CSVs.
|
|
317
320
|
target_dir = csv_dir if csv_dir else f"{plot_dir}/../results"
|
|
318
321
|
csv_path = save_dataframe_to_csv(
|
|
319
|
-
|
|
322
|
+
df_plot,
|
|
320
323
|
artifact="trajectories",
|
|
321
324
|
entity_name=entity_name,
|
|
322
325
|
temporal_granularity=temporal_granularity,
|
|
@@ -67,6 +67,23 @@ def test_fit_priority_matrix_stats():
|
|
|
67
67
|
assert stats["temporal_granularity"] == "quarterly"
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def test_fit_priority_matrix_monthly_basic():
|
|
71
|
+
"""Monthly granularity should run and report correct stats flag."""
|
|
72
|
+
|
|
73
|
+
df = generate_test_data()
|
|
74
|
+
|
|
75
|
+
results, stats = fit_priority_matrix(
|
|
76
|
+
df,
|
|
77
|
+
entity_col="entity",
|
|
78
|
+
timestamp_col="date",
|
|
79
|
+
temporal_granularity="monthly",
|
|
80
|
+
min_observations=3,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert len(results) > 0
|
|
84
|
+
assert stats["temporal_granularity"] == "monthly"
|
|
85
|
+
|
|
86
|
+
|
|
70
87
|
def test_fit_priority_matrix_seed_control():
|
|
71
88
|
"""Verify explicit seeding produces deterministic random effects."""
|
|
72
89
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
import pandas as pd
|
|
5
5
|
|
|
6
|
-
RAW_DATA_URL = "https://raw.githubusercontent.com/okkymabruri/priorityx/main/examples/incidents/incidents.csv"
|
|
7
|
-
|
|
8
6
|
from priorityx.core.glmm import fit_priority_matrix
|
|
9
7
|
from priorityx.tracking.movement import track_cumulative_movement
|
|
10
8
|
from priorityx.tracking.transitions import extract_transitions
|
|
@@ -21,6 +19,8 @@ from priorityx.utils.helpers import (
|
|
|
21
19
|
save_dataframe_to_csv,
|
|
22
20
|
)
|
|
23
21
|
|
|
22
|
+
RAW_DATA_URL = "https://raw.githubusercontent.com/okkymabruri/priorityx/main/examples/incidents/incidents.csv"
|
|
23
|
+
|
|
24
24
|
# load data from GitHub raw
|
|
25
25
|
df = pd.read_csv(RAW_DATA_URL, parse_dates=["date"])
|
|
26
26
|
print("Loaded incidents from GitHub raw URL")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|