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.
@@ -0,0 +1,227 @@
1
+ """Display and summary utilities."""
2
+
3
+ import pandas as pd
4
+
5
+
6
+ def display_quadrant_summary(
7
+ results_df: pd.DataFrame,
8
+ entity_name: str = "Entity",
9
+ min_count: int = 10
10
+ ) -> None:
11
+ """
12
+ Print quadrant breakdown with counts and percentages.
13
+
14
+ Args:
15
+ results_df: Results from fit_priority_matrix()
16
+ entity_name: Name for entity type
17
+ min_count: Minimum count to include in summary (default: 10)
18
+
19
+ Examples:
20
+ >>> display_quadrant_summary(results_df, entity_name="Service")
21
+ """
22
+ # filter by minimum count if specified
23
+ if min_count > 0 and "count" in results_df.columns:
24
+ df = results_df[results_df["count"] >= min_count]
25
+ else:
26
+ df = results_df
27
+
28
+ print(f"\nQUADRANT ANALYSIS SUMMARY - {entity_name.upper()}S")
29
+
30
+ total_entities = len(df)
31
+
32
+ print(f"\nTotal {entity_name.lower()}s analyzed: {total_entities}")
33
+
34
+ if min_count > 0:
35
+ print(f"(Filtered to ≥{min_count} count)")
36
+
37
+ print("\nQuadrant Breakdown:")
38
+ print("-" * 40)
39
+
40
+ quadrant_order = ["Q1", "Q2", "Q3", "Q4"]
41
+ quadrant_labels = {
42
+ "Q1": "Q1 (Crisis - High volume, High growth)",
43
+ "Q2": "Q2 (Investigate - Low volume, High growth)",
44
+ "Q3": "Q3 (Monitor - Low volume, Low growth)",
45
+ "Q4": "Q4 (Low Priority - High volume, Low growth)",
46
+ }
47
+
48
+ for q in quadrant_order:
49
+ q_data = df[df["quadrant"] == q]
50
+ count = len(q_data)
51
+ pct = (count / total_entities * 100) if total_entities > 0 else 0
52
+
53
+ if count > 0:
54
+ print()
55
+ print(f"{quadrant_labels[q]}")
56
+ print(f"Count: {count} ({pct:.1f}%)")
57
+
58
+ # show top entities by count if available
59
+ if "count" in q_data.columns:
60
+ top_entities = q_data.nlargest(3, "count")
61
+ print("Top by volume:")
62
+ for _, row in top_entities.iterrows():
63
+ entity_val = row.get("entity", row.get("Topic", "Unknown"))
64
+ print(f" {entity_val} ({int(row['count'])} count)")
65
+
66
+
67
+ def display_transition_summary(
68
+ transitions_df: pd.DataFrame,
69
+ entity_name: str = "Entity"
70
+ ) -> None:
71
+ """
72
+ Print transition summary by risk level and type.
73
+
74
+ Args:
75
+ transitions_df: DataFrame from extract_transitions()
76
+ entity_name: Name for entity type
77
+
78
+ Examples:
79
+ >>> display_transition_summary(transitions_df, entity_name="Service")
80
+ """
81
+ if transitions_df.empty:
82
+ print(f"No transitions detected for {entity_name.lower()}s")
83
+ return
84
+
85
+ print(f"\nQUADRANT TRANSITION SUMMARY - {entity_name.upper()}S")
86
+
87
+ # group by risk level
88
+ risk_groups = transitions_df.groupby("risk_level")
89
+
90
+ for risk_level in ["critical", "high", "medium", "low"]:
91
+ if risk_level in risk_groups.groups:
92
+ group = risk_groups.get_group(risk_level)
93
+ print()
94
+ print(f"{risk_level.upper()} RISK TRANSITIONS ({len(group)} total):")
95
+ print("-" * 40)
96
+
97
+ # show up to 5 examples
98
+ for _, transition in group.head(5).iterrows():
99
+ print(f"{transition['entity']}")
100
+ print(
101
+ f" {transition['from_quadrant']} -> {transition['to_quadrant']} "
102
+ f"({transition['transition_quarter']})"
103
+ )
104
+ print(f" {transition['transition_type']}")
105
+ if 'volume_change' in transition:
106
+ print(f" Volume change: {transition['volume_change']:.3f}")
107
+ if 'growth_change' in transition:
108
+ print(f" Growth change: {transition['growth_change']:.3f}")
109
+ print() # blank line between transitions
110
+
111
+ if len(group) > 5:
112
+ print(f"... and {len(group) - 5} more")
113
+
114
+
115
+ def display_movement_summary(
116
+ movement_df: pd.DataFrame,
117
+ entity_name: str = "Entity"
118
+ ) -> None:
119
+ """
120
+ Print movement tracking summary.
121
+
122
+ Args:
123
+ movement_df: DataFrame from track_cumulative_movement()
124
+ entity_name: Name for entity type
125
+
126
+ Examples:
127
+ >>> display_movement_summary(movement_df, entity_name="Service")
128
+ """
129
+ if movement_df.empty:
130
+ print(f"No movement data for {entity_name.lower()}s")
131
+ return
132
+
133
+ print(f"\nCUMULATIVE MOVEMENT SUMMARY - {entity_name.upper()}S")
134
+
135
+ n_entities = movement_df["entity"].nunique()
136
+ n_periods = movement_df["quarter"].nunique()
137
+ n_observations = len(movement_df)
138
+
139
+ print(f"\nEntities tracked: {n_entities}")
140
+ print(f"Time periods: {n_periods}")
141
+ print(f"Total observations: {n_observations}")
142
+
143
+ # divergence analysis
144
+ if "quadrant_differs" in movement_df.columns:
145
+ divergences = movement_df["quadrant_differs"].sum()
146
+ div_pct = (divergences / n_observations * 100) if n_observations > 0 else 0
147
+ print(f"\nQuadrant divergences from global baseline: {divergences} ({div_pct:.1f}%)")
148
+ print("(Period quadrant != Global quadrant)")
149
+
150
+ # show entities with largest movements
151
+ if "x_delta" in movement_df.columns and "y_delta" in movement_df.columns:
152
+ entity_total_movement = movement_df.groupby("entity").agg({
153
+ "x_delta": lambda x: abs(x).sum(),
154
+ "y_delta": lambda x: abs(x).sum(),
155
+ })
156
+ entity_total_movement["total"] = (
157
+ entity_total_movement["x_delta"] + entity_total_movement["y_delta"]
158
+ )
159
+ top_movers = entity_total_movement.nlargest(5, "total")
160
+
161
+ print(f"\nTop {entity_name.lower()}s by total movement:")
162
+ print("-" * 40)
163
+ for entity, row in top_movers.iterrows():
164
+ print(f" {entity}: {row['total']:.2f} (X: {row['x_delta']:.2f}, Y: {row['y_delta']:.2f})")
165
+
166
+
167
+ def get_quadrant_counts(results_df: pd.DataFrame) -> pd.DataFrame:
168
+ """
169
+ Get count of entities in each quadrant.
170
+
171
+ Args:
172
+ results_df: Results from fit_priority_matrix()
173
+
174
+ Returns:
175
+ DataFrame with quadrant counts and percentages
176
+
177
+ Examples:
178
+ >>> counts = get_quadrant_counts(results_df)
179
+ >>> print(counts)
180
+ """
181
+ if results_df.empty:
182
+ return pd.DataFrame(columns=["quadrant", "count", "percentage"])
183
+
184
+ counts = results_df["quadrant"].value_counts().sort_index()
185
+ total = len(results_df)
186
+
187
+ summary = pd.DataFrame({
188
+ "quadrant": counts.index,
189
+ "count": counts.values,
190
+ "percentage": (counts.values / total * 100).round(1)
191
+ })
192
+
193
+ return summary
194
+
195
+
196
+ def get_transition_counts(transitions_df: pd.DataFrame) -> pd.DataFrame:
197
+ """
198
+ Get count of transitions by risk level.
199
+
200
+ Args:
201
+ transitions_df: DataFrame from extract_transitions()
202
+
203
+ Returns:
204
+ DataFrame with transition counts by risk level
205
+
206
+ Examples:
207
+ >>> counts = get_transition_counts(transitions_df)
208
+ >>> print(counts)
209
+ """
210
+ if transitions_df.empty:
211
+ return pd.DataFrame(columns=["risk_level", "count", "percentage"])
212
+
213
+ counts = transitions_df["risk_level"].value_counts()
214
+ total = len(transitions_df)
215
+
216
+ summary = pd.DataFrame({
217
+ "risk_level": counts.index,
218
+ "count": counts.values,
219
+ "percentage": (counts.values / total * 100).round(1)
220
+ })
221
+
222
+ # sort by risk level
223
+ risk_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "stable": 4}
224
+ summary["order"] = summary["risk_level"].map(risk_order)
225
+ summary = summary.sort_values("order").drop("order", axis=1)
226
+
227
+ return summary
File without changes
@@ -0,0 +1,314 @@
1
+ """Priority matrix scatter plot visualization."""
2
+
3
+ from typing import List, Optional, Tuple
4
+
5
+ import matplotlib.pyplot as plt
6
+ import matplotlib.ticker as ticker
7
+ import pandas as pd
8
+ from matplotlib.lines import Line2D
9
+
10
+ # try to import adjustText for label positioning
11
+ try:
12
+ from adjustText import adjust_text
13
+ ADJUSTTEXT_AVAILABLE = True
14
+ except ImportError:
15
+ ADJUSTTEXT_AVAILABLE = False
16
+ print("Warning: adjustText not available. Labels may overlap.")
17
+
18
+
19
+ def plot_priority_matrix(
20
+ results_df: pd.DataFrame,
21
+ entity_name: str = "Entity",
22
+ title: Optional[str] = None,
23
+ figsize: Tuple[int, int] = (16, 12),
24
+ top_n_labels: int = 5,
25
+ show_quadrant_labels: bool = False,
26
+ bubble_col: Optional[str] = None,
27
+ force_show_labels: Optional[List[str]] = None,
28
+ force_hide_labels: Optional[List[str]] = None,
29
+ skip_label_min_count: int = 0,
30
+ save_plot: bool = False,
31
+ save_csv: bool = False,
32
+ output_dir: str = "plot",
33
+ temporal_granularity: str = "quarterly",
34
+ ) -> plt.Figure:
35
+ """
36
+ Visualize priority matrix as scatter plot.
37
+
38
+ Creates a quadrant-based scatter plot showing entity positions based on
39
+ GLMM random effects (volume and growth).
40
+
41
+ Args:
42
+ results_df: Results from fit_priority_matrix()
43
+ Required columns: entity, Random_Intercept, Random_Slope, quadrant, count
44
+ entity_name: Name for entity type (e.g., "Service", "Component")
45
+ title: Optional custom title
46
+ figsize: Figure size (width, height)
47
+ top_n_labels: Number of entities to label per quadrant
48
+ show_quadrant_labels: Show quadrant descriptions as background text
49
+ bubble_col: Optional column for bubble sizing (e.g., "count")
50
+ force_show_labels: List of entity names to always label
51
+ force_hide_labels: List of entity names to never label
52
+ skip_label_min_count: Skip labeling entities with count < threshold
53
+ save_plot: Save plot to file
54
+ save_csv: Save data to CSV
55
+ output_dir: Output directory for saved files
56
+ temporal_granularity: Time granularity ('quarterly', 'yearly', 'semiannual')
57
+
58
+ Returns:
59
+ Matplotlib figure
60
+
61
+ Examples:
62
+ >>> # basic plot
63
+ >>> fig = plot_priority_matrix(results_df, entity_name="Service")
64
+
65
+ >>> # with custom bubble sizing and labels
66
+ >>> fig = plot_priority_matrix(
67
+ ... results_df,
68
+ ... entity_name="Component",
69
+ ... bubble_col="count",
70
+ ... top_n_labels=10,
71
+ ... save_plot=True
72
+ ... )
73
+ """
74
+ # make a copy to avoid modifying original
75
+ df = results_df.copy()
76
+
77
+ # determine bubble sizing
78
+ if bubble_col and bubble_col in df.columns:
79
+ # use log scaling for bubble sizes
80
+ import numpy as np
81
+ df["size"] = 100 + 900 * (np.log1p(df[bubble_col]) / np.log1p(df[bubble_col].max()))
82
+ else:
83
+ # uniform bubble size
84
+ df["size"] = 200
85
+
86
+ # select entities to label
87
+ df_labelable = df.copy()
88
+ if skip_label_min_count > 0 and "count" in df.columns:
89
+ df_labelable = df[df["count"] >= skip_label_min_count]
90
+
91
+ # get top N by volume and growth in each quadrant
92
+ topics_to_label = {}
93
+ for q in ["Q1", "Q2", "Q3", "Q4"]:
94
+ topics_to_label[q] = []
95
+ q_data = df_labelable[df_labelable["quadrant"] == q]
96
+
97
+ if not q_data.empty:
98
+ # top by volume
99
+ if "count" in q_data.columns:
100
+ top_volume = q_data.nlargest(top_n_labels, "count").index.tolist()
101
+ topics_to_label[q].extend(top_volume)
102
+
103
+ # top by growth
104
+ top_growth = q_data.nlargest(top_n_labels, "Random_Slope").index.tolist()
105
+ for idx in top_growth:
106
+ if idx not in topics_to_label[q]:
107
+ topics_to_label[q].append(idx)
108
+
109
+ # apply manual label controls
110
+ if force_show_labels:
111
+ for label_name in force_show_labels:
112
+ matches = df[df["entity"] == label_name]
113
+ for idx in matches.index:
114
+ q = df.loc[idx, "quadrant"]
115
+ if idx not in topics_to_label[q]:
116
+ topics_to_label[q].append(idx)
117
+
118
+ if force_hide_labels:
119
+ for label_name in force_hide_labels:
120
+ matches = df[df["entity"] == label_name]
121
+ for idx in matches.index:
122
+ q = df.loc[idx, "quadrant"]
123
+ if idx in topics_to_label[q]:
124
+ topics_to_label[q].remove(idx)
125
+
126
+ # create plot
127
+ plt.figure(figsize=figsize)
128
+
129
+ # define colors for each quadrant
130
+ colors = {
131
+ "Q1": "#c2104b", # red - crisis
132
+ "Q2": "#FF9636", # orange - investigate
133
+ "Q3": "#5cb85c", # green - monitor
134
+ "Q4": "#792f88", # purple - low priority
135
+ }
136
+
137
+ # plot all points
138
+ for q, color in colors.items():
139
+ q_data = df[df["quadrant"] == q]
140
+ if not q_data.empty:
141
+ plt.scatter(
142
+ q_data["Random_Intercept"],
143
+ q_data["Random_Slope"],
144
+ s=q_data["size"],
145
+ color=color,
146
+ alpha=0.7,
147
+ label=f"Q{q[-1]}",
148
+ zorder=2,
149
+ )
150
+
151
+ # add axis lines
152
+ plt.axhline(0, color="grey", linestyle="--", alpha=0.7, linewidth=1)
153
+ plt.axvline(0, color="grey", linestyle="--", alpha=0.7, linewidth=1)
154
+
155
+ # get current axis
156
+ ax = plt.gca()
157
+
158
+ # add quadrant labels as background text
159
+ if show_quadrant_labels:
160
+ xlim = ax.get_xlim()
161
+ ylim = ax.get_ylim()
162
+
163
+ # compute quadrant centers
164
+ quadrant_centers = {
165
+ "Q1": ((0 + xlim[1]) / 2, (0 + ylim[1]) / 2), # top right
166
+ "Q2": ((xlim[0] + 0) / 2, (0 + ylim[1]) / 2), # top left
167
+ "Q3": ((xlim[0] + 0) / 2, (ylim[0] + 0) / 2), # bottom left
168
+ "Q4": ((0 + xlim[1]) / 2, (ylim[0] + 0) / 2), # bottom right
169
+ }
170
+
171
+ # quadrant descriptions
172
+ quadrant_labels = {
173
+ "Q1": "Q1: Crisis\n(High volume, High growth)",
174
+ "Q2": "Q2: Investigate\n(Low volume, High growth)",
175
+ "Q3": "Q3: Monitor\n(Low volume, Low growth)",
176
+ "Q4": "Q4: Low Priority\n(High volume, Low growth)",
177
+ }
178
+
179
+ # place labels
180
+ for q, (cx, cy) in quadrant_centers.items():
181
+ ax.text(
182
+ cx, cy,
183
+ quadrant_labels[q],
184
+ ha="center",
185
+ va="center",
186
+ fontsize=14,
187
+ color="gray",
188
+ alpha=0.45,
189
+ zorder=0,
190
+ fontweight="bold",
191
+ )
192
+
193
+ # add entity labels
194
+ texts = []
195
+ for q in ["Q1", "Q2", "Q3", "Q4"]:
196
+ q_data = df[df["quadrant"] == q]
197
+ for entity_idx in topics_to_label[q]:
198
+ if entity_idx in q_data.index:
199
+ row = q_data.loc[entity_idx]
200
+ label = row["entity"]
201
+
202
+ text = plt.text(
203
+ row["Random_Intercept"],
204
+ row["Random_Slope"],
205
+ label,
206
+ fontsize=14,
207
+ ha="center",
208
+ va="center",
209
+ )
210
+ texts.append(text)
211
+
212
+ # use adjustText to prevent overlaps if available
213
+ if ADJUSTTEXT_AVAILABLE and texts:
214
+ adjust_text(
215
+ texts,
216
+ arrowprops=dict(
217
+ arrowstyle="->", color="gray", lw=1.0, alpha=0.7, shrinkA=5, shrinkB=5
218
+ ),
219
+ expand_points=(1.5, 1.5),
220
+ expand_text=(1.2, 1.2),
221
+ force_text=(0.5, 0.5),
222
+ force_points=(0.3, 0.3),
223
+ )
224
+
225
+ # fix y-axis formatter
226
+ ax.yaxis.set_major_locator(
227
+ ticker.MaxNLocator(integer=False, steps=[1, 2, 5], nbins=7)
228
+ )
229
+ ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.1f"))
230
+
231
+ # create legend with equal-sized bubbles
232
+ legend_elements = []
233
+ for q, color in colors.items():
234
+ legend_elements.append(
235
+ Line2D(
236
+ [0], [0],
237
+ marker="o",
238
+ color="w",
239
+ markerfacecolor=color,
240
+ markersize=10,
241
+ label=f"Quadrant {q[-1]}",
242
+ )
243
+ )
244
+
245
+ # set labels
246
+ plt.xlabel(f"{entity_name} Volume (Relative)", fontsize=13)
247
+ plt.ylabel(f"{entity_name} Growth Rate (Relative)", fontsize=13)
248
+
249
+ # add title if provided
250
+ if title:
251
+ plt.title(title, fontsize=16, fontweight="bold", pad=20)
252
+
253
+ # place legend
254
+ plt.legend(
255
+ handles=legend_elements, loc="lower right", frameon=False, title="Quadrants"
256
+ )
257
+
258
+ plt.tight_layout()
259
+
260
+ # add bubble size note if applicable
261
+ if bubble_col:
262
+ xlim = ax.get_xlim()
263
+ ylim = ax.get_ylim()
264
+ ax.text(
265
+ xlim[0] + (xlim[1] - xlim[0]) * 0.02,
266
+ ylim[0] + (ylim[1] - ylim[0]) * 0.02,
267
+ f"Bubble size represents {bubble_col}",
268
+ ha="left",
269
+ va="bottom",
270
+ fontsize=10,
271
+ style="italic",
272
+ alpha=0.7,
273
+ zorder=3,
274
+ )
275
+
276
+ # save plot if requested
277
+ if save_plot:
278
+ import os
279
+ from datetime import datetime
280
+
281
+ os.makedirs(output_dir, exist_ok=True)
282
+ timestamp = datetime.now().strftime("%Y%m%d")
283
+ granularity_suffix = {
284
+ "quarterly": "Q",
285
+ "yearly": "Y",
286
+ "semiannual": "S",
287
+ }.get(temporal_granularity, "Q")
288
+ save_path = f"{output_dir}/priority_matrix-{entity_name.lower()}-{granularity_suffix}-{timestamp}.png"
289
+
290
+ plt.savefig(save_path, dpi=300, bbox_inches="tight", format="png")
291
+ print(f"Plot saved: {save_path}")
292
+
293
+ # save CSV if requested
294
+ if save_csv:
295
+ import os
296
+ from datetime import datetime
297
+
298
+ os.makedirs(f"{output_dir}/../results", exist_ok=True)
299
+ timestamp = datetime.now().strftime("%Y%m%d")
300
+ granularity_suffix = {
301
+ "quarterly": "Q",
302
+ "yearly": "Y",
303
+ "semiannual": "S",
304
+ }.get(temporal_granularity, "Q")
305
+ csv_path = f"{output_dir}/../results/priority_matrix-{entity_name.lower()}-{granularity_suffix}-{timestamp}.csv"
306
+
307
+ # save key columns
308
+ cols_to_save = ["entity", "quadrant", "Random_Intercept", "Random_Slope", "count"]
309
+ df[[c for c in cols_to_save if c in df.columns]].to_csv(
310
+ csv_path, index=False
311
+ )
312
+ print(f"CSV saved: {csv_path}")
313
+
314
+ return plt.gcf()