|
| 1 | +"""pyplots.ai |
| 2 | +heatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact) |
| 3 | +Library: letsplot 4.9.0 | Python 3.14.3 |
| 4 | +Quality: 80/100 | Created: 2026-03-17 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from lets_plot import * |
| 10 | + |
| 11 | + |
| 12 | +LetsPlot.setup_html() |
| 13 | + |
| 14 | +# Data |
| 15 | +np.random.seed(42) |
| 16 | + |
| 17 | +likelihood_labels = ["Rare", "Unlikely", "Possible", "Likely", "Almost\nCertain"] |
| 18 | +impact_labels = ["Negligible", "Minor", "Moderate", "Major", "Catastrophic"] |
| 19 | + |
| 20 | +# Build background grid with risk zones |
| 21 | +grid_rows = [] |
| 22 | +for li in range(1, 6): |
| 23 | + for im in range(1, 6): |
| 24 | + score = li * im |
| 25 | + if score <= 4: |
| 26 | + zone = "Low" |
| 27 | + elif score <= 9: |
| 28 | + zone = "Medium" |
| 29 | + elif score <= 16: |
| 30 | + zone = "High" |
| 31 | + else: |
| 32 | + zone = "Critical" |
| 33 | + grid_rows.append({"likelihood": li, "impact": im, "score": score, "zone": zone, "score_label": str(score)}) |
| 34 | +grid_df = pd.DataFrame(grid_rows) |
| 35 | +grid_df["zone"] = pd.Categorical(grid_df["zone"], categories=["Low", "Medium", "High", "Critical"], ordered=True) |
| 36 | + |
| 37 | +# Risk items with shorter names to avoid label overlap |
| 38 | +risks = pd.DataFrame( |
| 39 | + { |
| 40 | + "risk_name": [ |
| 41 | + "Server Outage", |
| 42 | + "Data Breach", |
| 43 | + "Budget Overrun", |
| 44 | + "Staff Loss", |
| 45 | + "Vendor Fail", |
| 46 | + "Scope Creep", |
| 47 | + "Reg. Change", |
| 48 | + "Tech Debt", |
| 49 | + "Integ. Bug", |
| 50 | + "Supply Delay", |
| 51 | + "Currency", |
| 52 | + "PR Crisis", |
| 53 | + "Patent Issue", |
| 54 | + "Power Outage", |
| 55 | + "Cyber Attack", |
| 56 | + ], |
| 57 | + "likelihood": [4, 3, 4, 2, 2, 5, 3, 4, 3, 1, 3, 1, 1, 2, 5], |
| 58 | + "impact": [5, 5, 3, 4, 3, 2, 3, 2, 4, 3, 2, 5, 4, 1, 5], |
| 59 | + "category": [ |
| 60 | + "Technical", |
| 61 | + "Technical", |
| 62 | + "Financial", |
| 63 | + "Operational", |
| 64 | + "Operational", |
| 65 | + "Operational", |
| 66 | + "Financial", |
| 67 | + "Technical", |
| 68 | + "Technical", |
| 69 | + "Operational", |
| 70 | + "Financial", |
| 71 | + "Operational", |
| 72 | + "Financial", |
| 73 | + "Technical", |
| 74 | + "Technical", |
| 75 | + ], |
| 76 | + } |
| 77 | +) |
| 78 | + |
| 79 | +# Compute risk score for size mapping (visual hierarchy) |
| 80 | +risks["risk_score"] = risks["likelihood"] * risks["impact"] |
| 81 | + |
| 82 | +# Smart jitter: offset risks sharing same cell |
| 83 | +cell_counts = {} |
| 84 | +offsets_x = [] |
| 85 | +offsets_y = [] |
| 86 | +for _, row in risks.iterrows(): |
| 87 | + cell = (row["likelihood"], row["impact"]) |
| 88 | + idx = cell_counts.get(cell, 0) |
| 89 | + cell_counts[cell] = idx + 1 |
| 90 | + offset_patterns = [(0, 0.15), (0, -0.15), (-0.2, 0), (0.2, 0)] |
| 91 | + ox, oy = offset_patterns[idx % len(offset_patterns)] |
| 92 | + offsets_x.append(ox) |
| 93 | + offsets_y.append(oy) |
| 94 | + |
| 95 | +risks["lk_jitter"] = risks["likelihood"] + np.array(offsets_x) |
| 96 | +risks["im_jitter"] = risks["impact"] + np.array(offsets_y) |
| 97 | + |
| 98 | +# Assign label nudge using checkerboard pattern based on grid position |
| 99 | +# This ensures adjacent cells always have opposite nudge directions |
| 100 | +nudge_vals = [] |
| 101 | +for _, row in risks.iterrows(): |
| 102 | + li, imp = int(row["likelihood"]), int(row["impact"]) |
| 103 | + if (li + imp) % 2 == 0: |
| 104 | + nudge_vals.append(0.33) |
| 105 | + else: |
| 106 | + nudge_vals.append(-0.33) |
| 107 | +risks["label_nudge_y"] = nudge_vals |
| 108 | + |
| 109 | +# Tooltips for lets-plot distinctive interactivity |
| 110 | +risk_tooltips = ( |
| 111 | + layer_tooltips() |
| 112 | + .line("@risk_name") |
| 113 | + .line("Category: @category") |
| 114 | + .line("Likelihood: @likelihood") |
| 115 | + .line("Impact: @impact") |
| 116 | + .line("Risk Score: @risk_score") |
| 117 | +) |
| 118 | + |
| 119 | +# Zone palette: green-yellow-orange-red as specified |
| 120 | +zone_colors = {"Low": "#2A9D8F", "Medium": "#E9C46A", "High": "#F4A261", "Critical": "#C1121F"} |
| 121 | + |
| 122 | +# Category colors |
| 123 | +cat_colors = {"Technical": "#306998", "Financial": "#7B2D8E", "Operational": "#D35400"} |
| 124 | + |
| 125 | +# Split risks for alternating label nudge |
| 126 | +risks_up = risks[risks["label_nudge_y"] > 0].copy() |
| 127 | +risks_down = risks[risks["label_nudge_y"] < 0].copy() |
| 128 | + |
| 129 | +# Plot |
| 130 | +plot = ( |
| 131 | + ggplot() |
| 132 | + + geom_tile(aes(x="likelihood", y="impact", fill="zone"), data=grid_df, color="white", size=2, tooltips="none") |
| 133 | + + geom_text(aes(x="likelihood", y="impact", label="score_label"), data=grid_df, size=13, color="rgba(0,0,0,0.12)") |
| 134 | + + geom_point( |
| 135 | + aes(x="lk_jitter", y="im_jitter", color="category", size="risk_score"), |
| 136 | + data=risks, |
| 137 | + alpha=0.92, |
| 138 | + tooltips=risk_tooltips, |
| 139 | + ) |
| 140 | + + scale_size(range=[4, 12], name="Risk Score", guide="none") |
| 141 | + + scale_fill_manual(values=zone_colors, name="Risk Level") |
| 142 | + + scale_color_manual(values=cat_colors, name="Category") |
| 143 | + + scale_x_continuous(breaks=[1, 2, 3, 4, 5], labels=likelihood_labels, limits=[0.4, 5.8]) |
| 144 | + + scale_y_continuous(breaks=[1, 2, 3, 4, 5], labels=impact_labels, limits=[0.4, 5.6]) |
| 145 | + + coord_fixed(ratio=1) |
| 146 | + + labs( |
| 147 | + x="Likelihood", |
| 148 | + y="Impact", |
| 149 | + title="heatmap-risk-matrix · lets-plot · pyplots.ai", |
| 150 | + subtitle="Risk score = Likelihood × Impact | Marker size scales with severity", |
| 151 | + ) |
| 152 | + + theme_minimal() |
| 153 | + + theme( |
| 154 | + plot_title=element_text(size=24, face="bold"), |
| 155 | + plot_subtitle=element_text(size=15, color="#555555"), |
| 156 | + axis_title=element_text(size=20, face="bold"), |
| 157 | + axis_text=element_text(size=15), |
| 158 | + legend_title=element_text(size=17, face="bold"), |
| 159 | + legend_text=element_text(size=14), |
| 160 | + panel_grid=element_blank(), |
| 161 | + ) |
| 162 | + + ggsize(1600, 900) |
| 163 | +) |
| 164 | + |
| 165 | +# Add labels with alternating nudge directions to reduce overlap |
| 166 | +if len(risks_up) > 0: |
| 167 | + plot = plot + geom_text( |
| 168 | + aes(x="lk_jitter", y="im_jitter", label="risk_name"), data=risks_up, size=9, nudge_y=0.33, fontface="bold" |
| 169 | + ) |
| 170 | + |
| 171 | +if len(risks_down) > 0: |
| 172 | + plot = plot + geom_text( |
| 173 | + aes(x="lk_jitter", y="im_jitter", label="risk_name"), data=risks_down, size=9, nudge_y=-0.33, fontface="bold" |
| 174 | + ) |
| 175 | + |
| 176 | +# Save |
| 177 | +ggsave(plot, "plot.png", path=".", scale=3) |
| 178 | +ggsave(plot, "plot.html", path=".") |
0 commit comments