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