Source code for ergodic_insurance.visualization.improved_tower_plot

"""Improved insurance tower visualization with stacked bar chart and smart annotations."""

from typing import Dict, List, Tuple, Union

from matplotlib.figure import Figure
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd


[docs] def plot_insurance_tower( # pylint: disable=too-many-locals,too-many-branches,too-many-statements layers: Union[List[Dict[str, float]], pd.DataFrame], title: str = "Insurance Tower Structure", figsize: Tuple[int, int] = (10, 12), min_height_for_text: float = 0.02, # Minimum relative height to display text inside (lower for log scale) color_scheme: str = "viridis", show_summary: bool = True, log_scale: bool = True, # Use logarithmic scale for better layer visibility ) -> Figure: """Create an improved insurance tower visualization with stacked bars and smart annotations. Args: layers: List of layer dictionaries or DataFrame with columns: - attachment: Layer attachment point - limit: Layer limit - premium: Premium amount or rate - expected_loss (optional): Expected loss for the layer - rate_on_line (optional): Rate on line for the layer title: Plot title figsize: Figure size (width, height) min_height_for_text: Minimum relative height (as fraction of total) to display text inside bar color_scheme: Matplotlib colormap name show_summary: Whether to show summary statistics log_scale: Use logarithmic scale for y-axis to better show layers of different sizes Returns: Matplotlib figure with improved tower visualization """ # Convert DataFrame to list of dicts if needed if isinstance(layers, pd.DataFrame): layer_list = [] for _, row in layers.iterrows(): layer_dict = { "attachment": row.get("attachment", 0), "limit": row.get("limit", 0), "premium": row.get("premium", 0), "expected_loss": row.get("expected_loss", None), "rate_on_line": row.get("rate_on_line", None), } layer_list.append(layer_dict) layers = layer_list if not layers: fig, ax = plt.subplots(figsize=figsize) ax.text( 0.5, 0.5, "No layers defined", ha="center", va="center", transform=ax.transAxes, fontsize=14, ) return fig # Calculate total height for normalization total_limit = max(layer["attachment"] + layer["limit"] for layer in layers) # Create figure and axis fig, ax = plt.subplots(figsize=figsize) # Generate colors cmap = plt.cm.get_cmap(color_scheme) colors = [cmap(0.3 + 0.5 * i / len(layers)) for i in range(len(layers))] # Track annotations for layers that are too small external_annotations = [] # Plot stacked bars (vertical) bar_width = 0.6 bar_center = 0.5 # If using log scale and first layer starts at 0, we need to handle it specially if log_scale: # Find minimum non-zero value for log scale base non_zero_attachments = [layer["attachment"] for layer in layers if layer["attachment"] > 0] if non_zero_attachments: min_nonzero = min(non_zero_attachments) else: # If all attachments are 0, use smallest limit min_nonzero = min(layer["limit"] for layer in layers) / 10 # Set a reasonable minimum for display log_scale_min = min_nonzero / 100 else: log_scale_min = 0 for i, (layer, color) in enumerate(zip(layers, colors)): attachment = layer["attachment"] limit = layer["limit"] # For log scale, we need to handle zero attachment specially if log_scale and attachment == 0: # Display the bar from a small positive value up to the limit display_bottom = log_scale_min display_height = limit - log_scale_min _rect = ax.bar( bar_center, display_height, bottom=display_bottom, width=bar_width, color=color, edgecolor="white", linewidth=2, alpha=0.8, ) # Store actual values for text positioning actual_attachment = 0.0 actual_limit = limit else: # Create the vertical bar normally _rect = ax.bar( bar_center, limit, bottom=attachment, width=bar_width, color=color, edgecolor="white", linewidth=2, alpha=0.8, ) actual_attachment = attachment actual_limit = limit # Determine if text fits inside # For log scale, check visual height rather than absolute height if log_scale: if actual_attachment == 0: visual_bottom = log_scale_min visual_top = actual_limit else: visual_bottom = actual_attachment visual_top = actual_attachment + actual_limit # Check if visual height is sufficient (log scale expands small layers) if visual_top > 0 and visual_bottom > 0: visual_height = np.log10(visual_top) - np.log10(visual_bottom) total_visual_height = np.log10(total_limit) - np.log10(log_scale_min) relative_height = ( visual_height / total_visual_height if total_visual_height > 0 else 0 ) else: relative_height = actual_limit / total_limit else: relative_height = actual_limit / total_limit # Format layer information layer_num = i + 1 attach_str = _format_currency(actual_attachment) limit_str = _format_currency(actual_limit) # Build layer label (compact for better fit) label_parts = [f"Layer {layer_num}"] label_parts.append(f"{limit_str} xs {attach_str}") # Add additional info only if there's room if relative_height > 0.08: # Only add extra info if plenty of room if layer.get("rate_on_line") is not None: label_parts.append(f"RoL: {layer['rate_on_line']:.2%}") elif layer.get("premium") is not None and layer["premium"] > 0: if layer["premium"] < 1: # Assume it's a rate label_parts.append(f"Premium: {layer['premium']:.2%}") else: label_parts.append(f"Premium: {_format_currency(layer['premium'])}") if relative_height >= min_height_for_text: # Text fits inside - place it in the center of the bar label = "\n".join(label_parts) # For log scale, use geometric mean for center position if log_scale: if actual_attachment == 0: # For base layer, position text in geometric center of visible bar text_y = ( np.sqrt(log_scale_min * actual_limit) if actual_limit > 0 else actual_limit / 2 ) else: # For other layers, geometric mean of top and bottom text_y = np.sqrt(actual_attachment * (actual_attachment + actual_limit)) else: text_y = actual_attachment + actual_limit / 2 ax.text( bar_center, text_y, label, ha="center", va="center", fontsize=10, fontweight="bold", color="white", bbox={"boxstyle": "round,pad=0.3", "facecolor": "black", "alpha": 0.3}, rotation=0, # Keep text horizontal ) else: # Text doesn't fit - store for external annotation external_annotations.append( { "layer_num": layer_num, "attachment": actual_attachment, "limit": actual_limit, "label_parts": label_parts, "color": color, } ) # Add external annotations for small layers (alternate left and right) for i, ann in enumerate(external_annotations): # Find the midpoint of the layer if log_scale: if ann["attachment"] == 0: # For base layer, use geometric mean with the log scale minimum layer_midpoint = np.sqrt(log_scale_min * ann["limit"]) else: # For other layers, geometric mean of top and bottom layer_midpoint = np.sqrt(ann["attachment"] * (ann["attachment"] + ann["limit"])) else: layer_midpoint = ann["attachment"] + ann["limit"] / 2 # Create annotation text (more compact for log scale) if log_scale: # In log scale, usually more room, so can be more detailed label = f"Layer {ann['layer_num']}: {_format_currency(ann['limit'])} xs {_format_currency(ann['attachment'])}" if len(ann["label_parts"]) > 2: label += "\n" + " • ".join(ann["label_parts"][2:]) else: label = " • ".join(ann["label_parts"][:2]) if len(ann["label_parts"]) > 2: label += "\n" + " • ".join(ann["label_parts"][2:]) # Alternate between left and right sides if i % 2 == 0: # Left side annotation x_text = 0.15 ha = "left" else: # Right side annotation x_text = 0.85 ha = "right" # Add annotation with arrow ax.annotate( label, xy=(bar_center, layer_midpoint), xytext=(x_text, layer_midpoint), xycoords="data", textcoords=("axes fraction", "data"), fontsize=8, ha=ha, va="center", bbox={ "boxstyle": "round,pad=0.3", "facecolor": ann["color"], "alpha": 0.7, "edgecolor": "white", }, arrowprops={ "arrowstyle": "->", "connectionstyle": "arc3,rad=0.2", "color": "gray", "linestyle": "--", "alpha": 0.7, "linewidth": 1, }, ) # Customize plot if log_scale: ax.set_yscale("log") # For log scale, set limits with a small buffer ax.set_ylim( log_scale_min / 2, total_limit * 1.5 if external_annotations else total_limit * 1.1 ) else: ax.set_ylim(0, total_limit * 1.3 if external_annotations else total_limit * 1.05) ax.set_xlim(0, 1) ax.set_ylabel( "Coverage Amount (Log Scale)" if log_scale else "Coverage Amount", fontsize=12, fontweight="bold", ) ax.set_title(title, fontsize=14, fontweight="bold", pad=20) # Format y-axis ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: _format_currency_axis(x))) # Remove x-axis ticks ax.set_xticks([]) ax.spines["bottom"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["top"].set_visible(False) # Add grid for y-axis ax.grid(True, axis="y", alpha=0.3, linestyle="--", which="both" if log_scale else "major") if log_scale: ax.grid(True, axis="y", alpha=0.15, linestyle=":", which="minor") # Add horizontal lines at attachment points for layer in layers: if layer["attachment"] > 0 or not log_scale: # Skip zero on log scale ax.axhline(y=layer["attachment"], color="gray", alpha=0.3, linestyle=":") ax.axhline(y=total_limit, color="gray", alpha=0.3, linestyle=":") # Add summary box if requested (positioned to avoid bar overlap) if show_summary: summary_text = _create_summary_text(layers) # Position summary box to the left side, away from the center bar ax.text( 0.02, 0.85, summary_text, transform=ax.transAxes, fontsize=9, verticalalignment="top", horizontalalignment="left", bbox={ "boxstyle": "round,pad=0.5", "facecolor": "white", "alpha": 0.95, "edgecolor": "gray", }, ) # Remove legend - it's not helpful for tower visualizations plt.tight_layout() return fig
def _format_currency(value: Union[float, str]) -> str: """Format currency values with appropriate units.""" # Handle string inputs that might already be formatted if isinstance(value, str): try: value = float(value) except ValueError: return str(value) # Return as-is if can't convert if value >= 1e9: return f"{value/1e9:.1f}B" if value >= 1e6: return f"{value/1e6:.1f}M" if value >= 1e3: return f"{value/1e3:.0f}K" return f"{value:.0f}" def _format_currency_axis(value: float) -> str: """Format currency values for axis labels.""" if value == 0: return "$0" if value >= 1e9: return f"${value/1e9:.1f}B" if value >= 1e6: return f"${value/1e6:.0f}M" if value >= 1e3: return f"${value/1e3:.0f}K" return f"${value:.0f}" def _create_summary_text(layers: List[Dict[str, float]]) -> str: """Create summary statistics text.""" total_limit = max(layer["attachment"] + layer["limit"] for layer in layers) total_premium = sum( layer.get("premium", 0) * (1 if layer.get("premium", 0) >= 1 else layer["limit"]) for layer in layers ) total_expected = sum(layer.get("expected_loss", 0) for layer in layers) lines = [ "Tower Summary:", f"Total Limit: {_format_currency(total_limit)}", f"Number of Layers: {len(layers)}", ] if total_premium > 0: lines.append(f"Total Premium: {_format_currency(total_premium)}") if total_expected > 0: lines.append(f"Total E[Loss]: {_format_currency(total_expected)}") if total_premium > 0: lines.append(f"Loss Ratio: {(total_expected/total_premium)*100:.1f}%") return "\n".join(lines)