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,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
|
priorityx/viz/matrix.py
ADDED
|
@@ -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()
|