<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_GB"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://gsehurut.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gsehurut.github.io/" rel="alternate" type="text/html" hreflang="en_GB" /><updated>2026-11-12T02:13:39+00:00</updated><id>https://gsehurut.github.io/feed.xml</id><title type="html">Goabaone Sehurutshe</title><subtitle>Actuarial Analytics Engineer building actuarial data products, pipelines and pricing analytics for the London specialty insurance market.</subtitle><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><entry><title type="html">Building Analytics Services for Actuaries and Underwriters With FastAPI</title><link href="https://gsehurut.github.io/blog/2026/11/02/building-analytics-services-fastapi/" rel="alternate" type="text/html" title="Building Analytics Services for Actuaries and Underwriters With FastAPI" /><published>2026-11-02T00:00:00+00:00</published><updated>2026-11-02T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2026/11/02/building-analytics-services-fastapi</id><content type="html" xml:base="https://gsehurut.github.io/blog/2026/11/02/building-analytics-services-fastapi/"><![CDATA[<p>In many insurance teams, the same calculation lives in five places: a pricing spreadsheet, a reserving notebook, a Power BI measure, a rating tool and somebody’s personal macro. Each copy drifts a little. When an assumption changes, someone has to remember to update all of them, and usually one gets missed.</p>

<p>Putting that logic behind a small, well tested web service solves this. There is one implementation, one set of tests, and every consumer (dashboards, notebooks, rating tools, even Excel) calls the same thing. This post shows how to build such a service with FastAPI, using a layer loss cost calculation as the example.</p>

<h2 id="why-a-service">Why a service?</h2>

<ul>
  <li><strong>One source of truth.</strong> The calculation is written, reviewed and tested once.</li>
  <li><strong>Consistency across tools.</strong> A notebook, a dashboard and an Excel sheet all get the same answer.</li>
  <li><strong>Change control.</strong> Updating an assumption is a reviewed code change with a version number, not an edit to a shared spreadsheet.</li>
  <li><strong>Auditability.</strong> Requests and responses can be logged, so you can show exactly what was calculated and when.</li>
</ul>

<p>FastAPI is a good fit for analytics teams already working in Python: it uses type hints for validation through Pydantic, generates interactive documentation automatically, and is fast enough for almost any internal workload.</p>

<h2 id="keep-the-maths-pure">Keep the maths pure</h2>

<p>The most important design decision is to keep the business logic in plain functions with no knowledge of HTTP. That makes it easy to test, reuse in notebooks, and review.</p>

<p>Here is the expected loss to an excess of loss layer when individual loss sizes follow a lognormal distribution. It uses the closed form limited expected value of the lognormal (see Klugman, Panjer and Willmot, <em>Loss Models</em>):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># pricing/layers.py
</span><span class="kn">import</span> <span class="nn">math</span>
<span class="kn">from</span> <span class="nn">scipy.stats</span> <span class="kn">import</span> <span class="n">norm</span>

<span class="k">def</span> <span class="nf">lognormal_lev</span><span class="p">(</span><span class="n">limit</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">mu</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">sigma</span><span class="p">:</span> <span class="nb">float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""Limited expected value E[min(X, limit)] for X ~ Lognormal(mu, sigma)."""</span>
    <span class="k">if</span> <span class="n">limit</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="mf">0.0</span>
    <span class="n">log_l</span> <span class="o">=</span> <span class="n">math</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">(</span>
        <span class="n">math</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="n">mu</span> <span class="o">+</span> <span class="n">sigma</span><span class="o">**</span><span class="mi">2</span> <span class="o">/</span> <span class="mi">2</span><span class="p">)</span> <span class="o">*</span> <span class="n">norm</span><span class="p">.</span><span class="n">cdf</span><span class="p">((</span><span class="n">log_l</span> <span class="o">-</span> <span class="n">mu</span> <span class="o">-</span> <span class="n">sigma</span><span class="o">**</span><span class="mi">2</span><span class="p">)</span> <span class="o">/</span> <span class="n">sigma</span><span class="p">)</span>
        <span class="o">+</span> <span class="n">limit</span> <span class="o">*</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="n">norm</span><span class="p">.</span><span class="n">cdf</span><span class="p">((</span><span class="n">log_l</span> <span class="o">-</span> <span class="n">mu</span><span class="p">)</span> <span class="o">/</span> <span class="n">sigma</span><span class="p">))</span>
    <span class="p">)</span>

<span class="k">def</span> <span class="nf">layer_expected_loss</span><span class="p">(</span>
    <span class="n">attachment</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">frequency</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">mu</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">sigma</span><span class="p">:</span> <span class="nb">float</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""Expected annual loss to a per-risk layer `limit` xs `attachment`."""</span>
    <span class="n">per_loss</span> <span class="o">=</span> <span class="n">lognormal_lev</span><span class="p">(</span><span class="n">attachment</span> <span class="o">+</span> <span class="n">limit</span><span class="p">,</span> <span class="n">mu</span><span class="p">,</span> <span class="n">sigma</span><span class="p">)</span> <span class="o">-</span> <span class="n">lognormal_lev</span><span class="p">(</span><span class="n">attachment</span><span class="p">,</span> <span class="n">mu</span><span class="p">,</span> <span class="n">sigma</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">frequency</span> <span class="o">*</span> <span class="n">per_loss</span>
</code></pre></div></div>

<h2 id="validate-at-the-edge-with-pydantic">Validate at the edge with Pydantic</h2>

<p>Request and response models define the contract. Pydantic rejects bad input with a clear error before it reaches the maths, which is exactly where validation belongs:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># api/schemas.py
</span><span class="kn">from</span> <span class="nn">pydantic</span> <span class="kn">import</span> <span class="n">BaseModel</span><span class="p">,</span> <span class="n">Field</span>

<span class="k">class</span> <span class="nc">LayerRequest</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">attachment</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="n">ge</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="s">"Layer attachment point"</span><span class="p">)</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="n">gt</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="s">"Layer limit"</span><span class="p">)</span>
    <span class="n">frequency</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="n">ge</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="s">"Expected ground-up losses per year"</span><span class="p">)</span>
    <span class="n">mu</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s">"Lognormal mu (log scale)"</span><span class="p">)</span>
    <span class="n">sigma</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="n">gt</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="s">"Lognormal sigma (log scale)"</span><span class="p">)</span>

<span class="k">class</span> <span class="nc">LayerResponse</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">expected_loss</span><span class="p">:</span> <span class="nb">float</span>
    <span class="n">rate_on_line</span><span class="p">:</span> <span class="nb">float</span>
    <span class="n">model_version</span><span class="p">:</span> <span class="nb">str</span>
</code></pre></div></div>

<h2 id="a-thin-api-layer">A thin API layer</h2>

<p>The endpoint does nothing except translate between HTTP and the pure function:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># api/main.py
</span><span class="kn">from</span> <span class="nn">fastapi</span> <span class="kn">import</span> <span class="n">FastAPI</span>
<span class="kn">from</span> <span class="nn">api.schemas</span> <span class="kn">import</span> <span class="n">LayerRequest</span><span class="p">,</span> <span class="n">LayerResponse</span>
<span class="kn">from</span> <span class="nn">pricing.layers</span> <span class="kn">import</span> <span class="n">layer_expected_loss</span>

<span class="n">MODEL_VERSION</span> <span class="o">=</span> <span class="s">"2026.11.0"</span>

<span class="n">app</span> <span class="o">=</span> <span class="n">FastAPI</span><span class="p">(</span><span class="n">title</span><span class="o">=</span><span class="s">"Pricing Analytics API"</span><span class="p">,</span> <span class="n">version</span><span class="o">=</span><span class="n">MODEL_VERSION</span><span class="p">)</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="s">"/v1/layers/expected-loss"</span><span class="p">,</span> <span class="n">response_model</span><span class="o">=</span><span class="n">LayerResponse</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">expected_loss</span><span class="p">(</span><span class="n">req</span><span class="p">:</span> <span class="n">LayerRequest</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LayerResponse</span><span class="p">:</span>
    <span class="n">el</span> <span class="o">=</span> <span class="n">layer_expected_loss</span><span class="p">(</span><span class="n">req</span><span class="p">.</span><span class="n">attachment</span><span class="p">,</span> <span class="n">req</span><span class="p">.</span><span class="n">limit</span><span class="p">,</span> <span class="n">req</span><span class="p">.</span><span class="n">frequency</span><span class="p">,</span> <span class="n">req</span><span class="p">.</span><span class="n">mu</span><span class="p">,</span> <span class="n">req</span><span class="p">.</span><span class="n">sigma</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">LayerResponse</span><span class="p">(</span>
        <span class="n">expected_loss</span><span class="o">=</span><span class="n">el</span><span class="p">,</span>
        <span class="n">rate_on_line</span><span class="o">=</span><span class="n">el</span> <span class="o">/</span> <span class="n">req</span><span class="p">.</span><span class="n">limit</span><span class="p">,</span>
        <span class="n">model_version</span><span class="o">=</span><span class="n">MODEL_VERSION</span><span class="p">,</span>
    <span class="p">)</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/health"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">health</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="k">return</span> <span class="p">{</span><span class="s">"status"</span><span class="p">:</span> <span class="s">"ok"</span><span class="p">,</span> <span class="s">"model_version"</span><span class="p">:</span> <span class="n">MODEL_VERSION</span><span class="p">}</span>
</code></pre></div></div>

<p>Run it with <code class="language-plaintext highlighter-rouge">uvicorn api.main:app --reload</code> and open <code class="language-plaintext highlighter-rouge">/docs</code> for interactive documentation that underwriters and analysts can try directly.</p>

<p>Two details are worth copying:</p>

<ul>
  <li><strong>Version in the path</strong> (<code class="language-plaintext highlighter-rouge">/v1/...</code>). When the contract has to change, add <code class="language-plaintext highlighter-rouge">/v2</code> and retire <code class="language-plaintext highlighter-rouge">/v1</code> gracefully instead of breaking every consumer at once.</li>
  <li><strong>Model version in every response.</strong> Anyone looking at a stored result can tell which version of the logic produced it.</li>
</ul>

<h2 id="test-both-layers">Test both layers</h2>

<p>Test the maths against known values and properties, and test the API contract separately:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tests/test_layers.py
</span><span class="kn">import</span> <span class="nn">math</span>
<span class="kn">import</span> <span class="nn">pytest</span>
<span class="kn">from</span> <span class="nn">fastapi.testclient</span> <span class="kn">import</span> <span class="n">TestClient</span>
<span class="kn">from</span> <span class="nn">api.main</span> <span class="kn">import</span> <span class="n">app</span>
<span class="kn">from</span> <span class="nn">pricing.layers</span> <span class="kn">import</span> <span class="n">lognormal_lev</span><span class="p">,</span> <span class="n">layer_expected_loss</span>

<span class="k">def</span> <span class="nf">test_lev_tends_to_mean_for_large_limits</span><span class="p">():</span>
    <span class="n">mu</span><span class="p">,</span> <span class="n">sigma</span> <span class="o">=</span> <span class="mf">13.0</span><span class="p">,</span> <span class="mf">1.2</span>
    <span class="k">assert</span> <span class="n">lognormal_lev</span><span class="p">(</span><span class="mf">1e15</span><span class="p">,</span> <span class="n">mu</span><span class="p">,</span> <span class="n">sigma</span><span class="p">)</span> <span class="o">==</span> <span class="n">pytest</span><span class="p">.</span><span class="n">approx</span><span class="p">(</span><span class="n">math</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="n">mu</span> <span class="o">+</span> <span class="n">sigma</span><span class="o">**</span><span class="mi">2</span> <span class="o">/</span> <span class="mi">2</span><span class="p">),</span> <span class="n">rel</span><span class="o">=</span><span class="mf">1e-6</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">test_stacked_layers_add_up</span><span class="p">():</span>
    <span class="n">args</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">frequency</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span> <span class="n">mu</span><span class="o">=</span><span class="mf">13.0</span><span class="p">,</span> <span class="n">sigma</span><span class="o">=</span><span class="mf">1.2</span><span class="p">)</span>
    <span class="n">lower</span> <span class="o">=</span> <span class="n">layer_expected_loss</span><span class="p">(</span><span class="mf">1e6</span><span class="p">,</span> <span class="mf">4e6</span><span class="p">,</span> <span class="o">**</span><span class="n">args</span><span class="p">)</span>
    <span class="n">upper</span> <span class="o">=</span> <span class="n">layer_expected_loss</span><span class="p">(</span><span class="mf">5e6</span><span class="p">,</span> <span class="mf">5e6</span><span class="p">,</span> <span class="o">**</span><span class="n">args</span><span class="p">)</span>
    <span class="n">combined</span> <span class="o">=</span> <span class="n">layer_expected_loss</span><span class="p">(</span><span class="mf">1e6</span><span class="p">,</span> <span class="mf">9e6</span><span class="p">,</span> <span class="o">**</span><span class="n">args</span><span class="p">)</span>
    <span class="k">assert</span> <span class="n">lower</span> <span class="o">+</span> <span class="n">upper</span> <span class="o">==</span> <span class="n">pytest</span><span class="p">.</span><span class="n">approx</span><span class="p">(</span><span class="n">combined</span><span class="p">)</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">TestClient</span><span class="p">(</span><span class="n">app</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">test_rejects_negative_attachment</span><span class="p">():</span>
    <span class="n">r</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="s">"/v1/layers/expected-loss"</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span>
        <span class="s">"attachment"</span><span class="p">:</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="s">"limit"</span><span class="p">:</span> <span class="mf">1e6</span><span class="p">,</span> <span class="s">"frequency"</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="s">"mu"</span><span class="p">:</span> <span class="mi">13</span><span class="p">,</span> <span class="s">"sigma"</span><span class="p">:</span> <span class="mf">1.2</span><span class="p">,</span>
    <span class="p">})</span>
    <span class="k">assert</span> <span class="n">r</span><span class="p">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">422</span>

<span class="k">def</span> <span class="nf">test_returns_version</span><span class="p">():</span>
    <span class="n">r</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="s">"/v1/layers/expected-loss"</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span>
        <span class="s">"attachment"</span><span class="p">:</span> <span class="mf">1e6</span><span class="p">,</span> <span class="s">"limit"</span><span class="p">:</span> <span class="mf">4e6</span><span class="p">,</span> <span class="s">"frequency"</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="s">"mu"</span><span class="p">:</span> <span class="mi">13</span><span class="p">,</span> <span class="s">"sigma"</span><span class="p">:</span> <span class="mf">1.2</span><span class="p">,</span>
    <span class="p">})</span>
    <span class="k">assert</span> <span class="n">r</span><span class="p">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">200</span>
    <span class="k">assert</span> <span class="n">r</span><span class="p">.</span><span class="n">json</span><span class="p">()[</span><span class="s">"model_version"</span><span class="p">]</span>
</code></pre></div></div>

<p>Property tests like “stacked layers add up to the combined layer” catch subtle errors that a single hard coded expected value would miss. Run the suite in CI on every pull request.</p>

<h2 id="meeting-underwriters-where-they-work-excel">Meeting underwriters where they work: Excel</h2>

<p>However good a web app is, many underwriters and actuaries live in Excel. Office Add-ins support <strong>custom functions</strong> written in JavaScript, which can call your API and return the result into a cell like any built in function:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * Expected annual loss to a layer.
 * @customfunction
 * @param {number} attachment
 * @param {number} limit
 * @param {number} frequency
 * @param {number} mu
 * @param {number} sigma
 * @returns {Promise&lt;number&gt;}
 */</span>
<span class="k">async</span> <span class="kd">function</span> <span class="nx">LAYERLOSS</span><span class="p">(</span><span class="nx">attachment</span><span class="p">,</span> <span class="nx">limit</span><span class="p">,</span> <span class="nx">frequency</span><span class="p">,</span> <span class="nx">mu</span><span class="p">,</span> <span class="nx">sigma</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://pricing-api.internal/v1/layers/expected-loss</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span> <span class="p">},</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">attachment</span><span class="p">,</span> <span class="nx">limit</span><span class="p">,</span> <span class="nx">frequency</span><span class="p">,</span> <span class="nx">mu</span><span class="p">,</span> <span class="nx">sigma</span> <span class="p">}),</span>
  <span class="p">});</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nx">CustomFunctions</span><span class="p">.</span><span class="nb">Error</span><span class="p">(</span>
      <span class="nx">CustomFunctions</span><span class="p">.</span><span class="nx">ErrorCode</span><span class="p">.</span><span class="nx">invalidValue</span><span class="p">,</span>
      <span class="s2">`Pricing API error </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">`</span>
    <span class="p">);</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="p">(</span><span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">()).</span><span class="nx">expected_loss</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">=LAYERLOSS(1000000, 4000000, 5, 13, 1.2)</code> in a cell calls the same tested logic as every other tool. In production you would add authentication (for example Microsoft Entra ID) so only authorised users can call the API.</p>

<h2 id="production-considerations">Production considerations</h2>

<ul>
  <li><strong>Configuration from the environment.</strong> Follow the Twelve-Factor App guidance: no credentials or environment specific URLs in code.</li>
  <li><strong>Authentication and authorisation.</strong> Internal does not mean open. Use your organisation’s identity provider.</li>
  <li><strong>Logging and monitoring.</strong> Log request IDs, timings and model versions; alert on error rates.</li>
  <li><strong>Containerise and deploy through CI/CD,</strong> with automated tests as a gate.</li>
  <li><strong>Keep it small.</strong> A focused service with a clear contract is easier to trust than a sprawling one. Newman (2021) is a good guide to drawing sensible service boundaries.</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<p>A service is not about technology for its own sake. It is about making sure that a pricing or reserving calculation exists once, is tested, is versioned, and gives the same answer wherever it is used. FastAPI and Pydantic make that achievable for any analytics team comfortable with Python.</p>

<h2 id="references">References</h2>

<ul>
  <li>FastAPI documentation. fastapi.tiangolo.com</li>
  <li>Pydantic documentation. docs.pydantic.dev</li>
  <li>Microsoft Learn. <em>Create custom functions in Excel</em> (Office Add-ins). learn.microsoft.com/office/dev/add-ins</li>
  <li>Wiggins, A. <em>The Twelve-Factor App</em>. 12factor.net</li>
  <li>Newman, S. (2021). <em>Building Microservices</em>, 2nd edition. O’Reilly Media.</li>
  <li>Klugman, S. A., Panjer, H. H. and Willmot, G. E. (2019). <em>Loss Models: From Data to Decisions</em>, 5th edition. Wiley.</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Software Engineering" /><category term="Data Engineering" /><category term="FastAPI" /><category term="Python" /><category term="Pydantic" /><category term="APIs" /><category term="Excel Add-ins" /><category term="Testing" /><summary type="html"><![CDATA[Why actuarial logic belongs in a service, how to structure one with FastAPI and Pydantic, test it properly, version it, and put it in front of underwriters through Excel.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Working With Microsoft Fabric for Actuarial Data: Lessons From Practice</title><link href="https://gsehurut.github.io/blog/2026/10/19/working-with-microsoft-fabric/" rel="alternate" type="text/html" title="Working With Microsoft Fabric for Actuarial Data: Lessons From Practice" /><published>2026-10-19T00:00:00+00:00</published><updated>2026-10-19T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2026/10/19/working-with-microsoft-fabric</id><content type="html" xml:base="https://gsehurut.github.io/blog/2026/10/19/working-with-microsoft-fabric/"><![CDATA[<p>Microsoft Fabric brings data engineering, warehousing and Power BI into a single platform built around one data lake, OneLake. For actuarial and insurance teams that already live in the Microsoft ecosystem, that is an attractive proposition: reserving data, pricing datasets and executive dashboards can share the same storage and governance instead of being copied between tools.</p>

<p>This post covers the decisions and habits that have mattered most when moving actuarial pipelines, such as reserving and loss triangle data, from locally run scripts onto scheduled Fabric pipelines.</p>

<h2 id="the-building-blocks-briefly">The building blocks, briefly</h2>

<ul>
  <li><strong>OneLake</strong> is a single logical data lake for the whole organisation. Every workspace stores its data there, in open Delta Parquet format.</li>
  <li><strong>Lakehouse</strong> items hold files and Delta tables, and are usually worked on with Spark notebooks. Each one comes with a read only <strong>SQL analytics endpoint</strong> for T-SQL queries.</li>
  <li><strong>Warehouse</strong> items offer a full read and write T-SQL experience over the same Delta format.</li>
  <li><strong>Data pipelines</strong> (from Data Factory) orchestrate copies, notebooks and stored procedures on a schedule.</li>
  <li><strong>Semantic models</strong> in <strong>Direct Lake</strong> mode let Power BI read Delta tables directly from OneLake without importing a copy.</li>
  <li><strong>Shortcuts</strong> let one lakehouse reference data in another location without duplicating it.</li>
</ul>

<h2 id="lakehouse-or-warehouse">Lakehouse or warehouse?</h2>

<p>Both store Delta tables in OneLake, so this is mainly a question of how your team works:</p>

<table>
  <thead>
    <tr>
      <th>If your team mostly…</th>
      <th>Lean towards</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>writes Python and PySpark, works with files and semi structured data</td>
      <td>Lakehouse</td>
    </tr>
    <tr>
      <td>writes T-SQL, wants stored procedures and multi table transactions</td>
      <td>Warehouse</td>
    </tr>
    <tr>
      <td>needs both</td>
      <td>Lakehouse for ingestion and transformation, Warehouse or SQL endpoint for serving</td>
    </tr>
  </tbody>
</table>

<p>For actuarial teams with a mix of Python and SQL skills, a lakehouse for ingestion and transformation with SQL access for analysts has worked well.</p>

<h2 id="medallion-layers-with-a-purpose">Medallion layers with a purpose</h2>

<p>Microsoft’s guidance recommends a medallion architecture: <strong>bronze</strong> for raw data as received, <strong>silver</strong> for cleaned and conformed data, <strong>gold</strong> for business ready models. The pattern is only useful if each layer has a clear contract:</p>

<ul>
  <li><strong>Bronze:</strong> files exactly as received (claims extracts, bordereaux, system exports), plus load metadata. Never edited.</li>
  <li><strong>Silver:</strong> validated, typed and deduplicated, at a documented grain, with consistent keys and currencies.</li>
  <li><strong>Gold:</strong> dimensional models and aggregates shaped for reporting: claim movement facts, triangles, premium by class.</li>
</ul>

<p>Keeping bronze immutable means that when a transformation bug is found, you can fix it and replay history rather than asking source teams for files again.</p>

<h2 id="idempotent-loads-with-delta-merge">Idempotent loads with Delta MERGE</h2>

<p>Scheduled pipelines fail, get rerun, and receive corrected files. Every load from bronze to silver should be safe to run twice. Delta Lake’s <code class="language-plaintext highlighter-rouge">MERGE</code> makes this straightforward in a Fabric notebook:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">pyspark.sql</span> <span class="kn">import</span> <span class="n">functions</span> <span class="k">as</span> <span class="n">F</span>
<span class="kn">from</span> <span class="nn">delta.tables</span> <span class="kn">import</span> <span class="n">DeltaTable</span>

<span class="n">valuation_date</span> <span class="o">=</span> <span class="s">"2026-09-30"</span>  <span class="c1"># passed in as a pipeline parameter in practice
</span>
<span class="n">incoming</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">spark</span><span class="p">.</span><span class="n">read</span><span class="p">.</span><span class="n">option</span><span class="p">(</span><span class="s">"header"</span><span class="p">,</span> <span class="bp">True</span><span class="p">)</span>
    <span class="p">.</span><span class="n">csv</span><span class="p">(</span><span class="sa">f</span><span class="s">"Files/bronze/claims/</span><span class="si">{</span><span class="n">valuation_date</span><span class="si">}</span><span class="s">/*.csv"</span><span class="p">)</span>
    <span class="p">.</span><span class="n">withColumn</span><span class="p">(</span><span class="s">"paid_to_date"</span><span class="p">,</span> <span class="n">F</span><span class="p">.</span><span class="n">col</span><span class="p">(</span><span class="s">"paid_to_date"</span><span class="p">).</span><span class="n">cast</span><span class="p">(</span><span class="s">"decimal(18,2)"</span><span class="p">))</span>
    <span class="p">.</span><span class="n">withColumn</span><span class="p">(</span><span class="s">"outstanding"</span><span class="p">,</span> <span class="n">F</span><span class="p">.</span><span class="n">col</span><span class="p">(</span><span class="s">"outstanding"</span><span class="p">).</span><span class="n">cast</span><span class="p">(</span><span class="s">"decimal(18,2)"</span><span class="p">))</span>
    <span class="p">.</span><span class="n">withColumn</span><span class="p">(</span><span class="s">"valuation_date"</span><span class="p">,</span> <span class="n">F</span><span class="p">.</span><span class="n">lit</span><span class="p">(</span><span class="n">valuation_date</span><span class="p">).</span><span class="n">cast</span><span class="p">(</span><span class="s">"date"</span><span class="p">))</span>
    <span class="p">.</span><span class="n">withColumn</span><span class="p">(</span><span class="s">"_loaded_at"</span><span class="p">,</span> <span class="n">F</span><span class="p">.</span><span class="n">current_timestamp</span><span class="p">())</span>
    <span class="p">.</span><span class="n">dropDuplicates</span><span class="p">([</span><span class="s">"claim_id"</span><span class="p">,</span> <span class="s">"valuation_date"</span><span class="p">])</span>
<span class="p">)</span>

<span class="n">target</span> <span class="o">=</span> <span class="n">DeltaTable</span><span class="p">.</span><span class="n">forName</span><span class="p">(</span><span class="n">spark</span><span class="p">,</span> <span class="s">"silver_claim_snapshot"</span><span class="p">)</span>

<span class="p">(</span>
    <span class="n">target</span><span class="p">.</span><span class="n">alias</span><span class="p">(</span><span class="s">"t"</span><span class="p">)</span>
    <span class="p">.</span><span class="n">merge</span><span class="p">(</span>
        <span class="n">incoming</span><span class="p">.</span><span class="n">alias</span><span class="p">(</span><span class="s">"s"</span><span class="p">),</span>
        <span class="s">"t.claim_id = s.claim_id AND t.valuation_date = s.valuation_date"</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="p">.</span><span class="n">whenMatchedUpdateAll</span><span class="p">()</span>
    <span class="p">.</span><span class="n">whenNotMatchedInsertAll</span><span class="p">()</span>
    <span class="p">.</span><span class="n">execute</span><span class="p">()</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Rerunning for the same valuation date updates rows rather than duplicating them. The same pattern, keyed on the documented grain, works for premium and exposure data.</p>

<h2 id="validate-before-you-merge">Validate before you merge</h2>

<p>A merge will happily write bad data. Add checks between reading and merging, and fail the notebook with a clear message so the pipeline run shows as failed:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">problems</span> <span class="o">=</span> <span class="p">[]</span>
<span class="k">if</span> <span class="n">incoming</span><span class="p">.</span><span class="nb">filter</span><span class="p">(</span><span class="n">F</span><span class="p">.</span><span class="n">col</span><span class="p">(</span><span class="s">"claim_id"</span><span class="p">).</span><span class="n">isNull</span><span class="p">()).</span><span class="n">limit</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="n">count</span><span class="p">()</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
    <span class="n">problems</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"null claim_id"</span><span class="p">)</span>
<span class="k">if</span> <span class="n">incoming</span><span class="p">.</span><span class="nb">filter</span><span class="p">(</span><span class="n">F</span><span class="p">.</span><span class="n">col</span><span class="p">(</span><span class="s">"paid_to_date"</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">).</span><span class="n">limit</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="n">count</span><span class="p">()</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
    <span class="n">problems</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"negative paid_to_date"</span><span class="p">)</span>

<span class="k">if</span> <span class="n">problems</span><span class="p">:</span>
    <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s">"Claims load </span><span class="si">{</span><span class="n">valuation_date</span><span class="si">}</span><span class="s"> rejected: </span><span class="si">{</span><span class="s">', '</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">problems</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="look-after-your-delta-tables">Look after your Delta tables</h2>

<p>Delta tables need some housekeeping to stay fast:</p>

<ul>
  <li><strong>Small files</strong> build up when you append little batches frequently. Run <code class="language-plaintext highlighter-rouge">OPTIMIZE</code> periodically to compact them.</li>
  <li><strong>Old versions</strong> accumulate for time travel. <code class="language-plaintext highlighter-rouge">VACUUM</code> removes files no longer needed beyond your retention period. Keep retention long enough for any “as at” queries you rely on.</li>
  <li><strong>V-Order</strong>, Fabric’s write time optimisation for Parquet, improves read performance for Power BI and SQL. It is worth understanding whether it is enabled for your workloads.</li>
  <li><strong>Partition sparingly.</strong> Partitioning a modest reserving table by date can create thousands of tiny files and make things slower. Partition only large tables, on columns you actually filter by.</li>
</ul>

<p>The Delta Lake paper by Armbrust et al. (2020) explains why these operations matter: the transaction log and file layout are what give Delta its reliability and performance.</p>

<h2 id="treat-workspaces-like-environments">Treat workspaces like environments</h2>

<p>Fabric supports <strong>Git integration</strong> (Azure DevOps or GitHub) for workspace items and <strong>deployment pipelines</strong> to promote content between development, test and production workspaces. Use them:</p>

<ul>
  <li>Develop in a dev workspace connected to a feature branch.</li>
  <li>Review notebook and pipeline changes through pull requests like any other code.</li>
  <li>Promote to production through a deployment pipeline, with parameters (lakehouse names, paths) swapped per stage.</li>
</ul>

<p>Keeping business logic in version controlled notebooks or Python modules, rather than in manual edits in production, is what makes a pipeline auditable.</p>

<h2 id="watch-your-capacity">Watch your capacity</h2>

<p>Fabric runs on capacities, and every workload (Spark, SQL, Power BI refreshes) draws from the same pool. A heavy notebook scheduled at the same time as the morning dashboard refresh can slow everything down. The <strong>Fabric Capacity Metrics</strong> app shows where capacity goes. Stagger schedules, right size Spark sessions, and avoid rerunning full history when an incremental load would do.</p>

<h2 id="serving-with-direct-lake">Serving with Direct Lake</h2>

<p>Once gold tables are in good shape, a Direct Lake semantic model lets Power BI read them straight from OneLake. Dashboards see fresh data without a separate import refresh, and measures are defined once in the semantic model rather than in every report. Gold tables designed as a clean star schema (see the data modelling post) make Direct Lake models far simpler.</p>

<h2 id="lessons-in-short">Lessons in short</h2>

<ol>
  <li>Keep bronze immutable and replayable.</li>
  <li>Give each medallion layer a written contract.</li>
  <li>Make every load idempotent with <code class="language-plaintext highlighter-rouge">MERGE</code> on the documented grain.</li>
  <li>Validate before writing, and fail loudly.</li>
  <li>Maintain Delta tables with <code class="language-plaintext highlighter-rouge">OPTIMIZE</code> and a considered <code class="language-plaintext highlighter-rouge">VACUUM</code> policy.</li>
  <li>Use Git integration and deployment pipelines; no hand edits in production.</li>
  <li>Monitor capacity and schedule workloads with care.</li>
</ol>

<h2 id="references">References</h2>

<ul>
  <li>Microsoft Learn. <em>What is Microsoft Fabric?</em> learn.microsoft.com/fabric</li>
  <li>Microsoft Learn. <em>Implement medallion lakehouse architecture in Microsoft Fabric</em>. learn.microsoft.com/fabric</li>
  <li>Microsoft Learn. <em>Direct Lake overview</em>. learn.microsoft.com/fabric</li>
  <li>Microsoft Learn. <em>Introduction to Git integration</em> and <em>Introduction to deployment pipelines</em> (Fabric lifecycle management). learn.microsoft.com/fabric</li>
  <li>Armbrust, M. et al. (2020). Delta Lake: high-performance ACID table storage over cloud object stores. <em>Proceedings of the VLDB Endowment</em>, 13(12), 3411 to 3424.</li>
  <li>Delta Lake documentation. docs.delta.io</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Data Engineering" /><category term="Microsoft Fabric" /><category term="Microsoft Fabric" /><category term="PySpark" /><category term="Delta Lake" /><category term="Lakehouse" /><category term="Power BI" /><category term="Data Engineering" /><summary type="html"><![CDATA[Lakehouse or warehouse, medallion layers, idempotent PySpark merges, Delta table maintenance, Git integration and capacity: practical lessons for running actuarial pipelines on Microsoft Fabric.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Data Modelling for Insurance Analytics: Grain, Stars and Time</title><link href="https://gsehurut.github.io/blog/2026/10/05/data-modelling-insurance-analytics/" rel="alternate" type="text/html" title="Data Modelling for Insurance Analytics: Grain, Stars and Time" /><published>2026-10-05T00:00:00+00:00</published><updated>2026-10-05T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2026/10/05/data-modelling-insurance-analytics</id><content type="html" xml:base="https://gsehurut.github.io/blog/2026/10/05/data-modelling-insurance-analytics/"><![CDATA[<p>Most problems I see in insurance reporting are not caused by bad SQL. They are caused by a data model that does not match the questions being asked. A good model makes the right answer the easy answer: premium cannot be double counted, history is available when you need it, and a reserving triangle is a simple query rather than a week of spreadsheet work.</p>

<p>This post walks through a dimensional model for premium and claims analytics, following the approach in Kimball and Ross’s <em>The Data Warehouse Toolkit</em>, and adapted for specialty insurance.</p>

<h2 id="start-from-the-questions">Start from the questions</h2>

<p>Before drawing any tables, list the questions the model must answer. For a specialty portfolio they usually include:</p>

<ul>
  <li>What premium have we written and earned, by line, year of account, broker and territory?</li>
  <li>How have incurred claims developed over time for each underwriting year?</li>
  <li>What is our exposure, at our share, to a given country, insured or scenario?</li>
  <li>How did any of the above look <em>as at</em> last quarter end?</li>
</ul>

<p>Each question tells you something about the grain, the dimensions and the history you need.</p>

<h2 id="facts-and-dimensions">Facts and dimensions</h2>

<p>A dimensional model separates <strong>facts</strong> (measurable events, usually numeric) from <strong>dimensions</strong> (the descriptive context you slice by).</p>

<p>A sensible core for specialty insurance:</p>

<p><strong>Facts</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">fact_premium_transaction</code>: one row per premium movement, per policy section, per layer, per currency.</li>
  <li><code class="language-plaintext highlighter-rouge">fact_claim_movement</code>: one row per claim payment or reserve change, per claim, per currency.</li>
  <li><code class="language-plaintext highlighter-rouge">fact_exposure_snapshot</code>: one row per risk, per reporting date, with limits and sums insured at our share.</li>
</ul>

<p><strong>Dimensions</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">dim_policy</code> (with section and layer attributes), <code class="language-plaintext highlighter-rouge">dim_class</code> (line of business hierarchy), <code class="language-plaintext highlighter-rouge">dim_broker</code>, <code class="language-plaintext highlighter-rouge">dim_insured</code>, <code class="language-plaintext highlighter-rouge">dim_geography</code>, <code class="language-plaintext highlighter-rouge">dim_currency</code>, and a <code class="language-plaintext highlighter-rouge">dim_date</code> used in several roles (transaction date, inception date, loss date).</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                 dim_date (as transaction / inception / loss date)
                        |
dim_broker --- fact_premium_transaction --- dim_policy --- dim_class
                        |
                  dim_currency
</code></pre></div></div>

<h2 id="why-transactions-beat-totals">Why transactions beat totals</h2>

<p>It is tempting to store one row per policy with “total premium” and “total incurred” columns. It feels simpler, but it throws away the information you need most:</p>

<ul>
  <li>You cannot see <strong>when</strong> amounts changed, so you cannot build development triangles.</li>
  <li>You cannot reproduce last quarter’s figures once totals are overwritten.</li>
  <li>Corrections overwrite history rather than appearing as visible adjustments.</li>
</ul>

<p>Storing <strong>movements</strong> (each payment, each reserve change, each additional premium) keeps all of that. Totals at any date are then just a sum of movements up to that date:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Incurred to date, as at any chosen valuation date</span>
<span class="k">SELECT</span>
    <span class="k">c</span><span class="p">.</span><span class="n">claim_id</span><span class="p">,</span>
    <span class="k">SUM</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">paid_amount</span><span class="p">)</span>    <span class="k">AS</span> <span class="n">paid_to_date</span><span class="p">,</span>
    <span class="k">SUM</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">reserve_change</span><span class="p">)</span> <span class="k">AS</span> <span class="n">outstanding</span><span class="p">,</span>
    <span class="k">SUM</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">paid_amount</span> <span class="o">+</span> <span class="n">m</span><span class="p">.</span><span class="n">reserve_change</span><span class="p">)</span> <span class="k">AS</span> <span class="n">incurred</span>
<span class="k">FROM</span> <span class="n">fact_claim_movement</span> <span class="k">AS</span> <span class="n">m</span>
<span class="k">JOIN</span> <span class="n">dim_claim</span> <span class="k">AS</span> <span class="k">c</span> <span class="k">ON</span> <span class="k">c</span><span class="p">.</span><span class="n">claim_key</span> <span class="o">=</span> <span class="n">m</span><span class="p">.</span><span class="n">claim_key</span>
<span class="k">WHERE</span> <span class="n">m</span><span class="p">.</span><span class="n">transaction_date</span> <span class="o">&lt;=</span> <span class="o">@</span><span class="n">valuation_date</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="k">c</span><span class="p">.</span><span class="n">claim_id</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="building-a-triangle-from-movements">Building a triangle from movements</h2>

<p>With claim movements in place, a reserving triangle is a straightforward aggregation. Development period is the gap between the start of the underwriting year and the transaction date:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">WITH</span> <span class="n">movements</span> <span class="k">AS</span> <span class="p">(</span>
    <span class="k">SELECT</span>
        <span class="n">p</span><span class="p">.</span><span class="n">underwriting_year</span><span class="p">,</span>
        <span class="c1">-- development quarter, 1 = first quarter of the underwriting year</span>
        <span class="p">(</span><span class="nb">YEAR</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">transaction_date</span><span class="p">)</span> <span class="o">-</span> <span class="n">p</span><span class="p">.</span><span class="n">underwriting_year</span><span class="p">)</span> <span class="o">*</span> <span class="mi">4</span>
            <span class="o">+</span> <span class="n">DATEPART</span><span class="p">(</span><span class="n">QUARTER</span><span class="p">,</span> <span class="n">m</span><span class="p">.</span><span class="n">transaction_date</span><span class="p">)</span> <span class="k">AS</span> <span class="n">dev_quarter</span><span class="p">,</span>
        <span class="n">m</span><span class="p">.</span><span class="n">paid_amount</span> <span class="o">+</span> <span class="n">m</span><span class="p">.</span><span class="n">reserve_change</span>           <span class="k">AS</span> <span class="n">incurred_movement</span>
    <span class="k">FROM</span> <span class="n">fact_claim_movement</span> <span class="k">AS</span> <span class="n">m</span>
    <span class="k">JOIN</span> <span class="n">dim_policy</span> <span class="k">AS</span> <span class="n">p</span> <span class="k">ON</span> <span class="n">p</span><span class="p">.</span><span class="n">policy_key</span> <span class="o">=</span> <span class="n">m</span><span class="p">.</span><span class="n">policy_key</span>
    <span class="k">WHERE</span> <span class="n">m</span><span class="p">.</span><span class="n">transaction_date</span> <span class="o">&lt;=</span> <span class="o">@</span><span class="n">valuation_date</span>
<span class="p">)</span>
<span class="k">SELECT</span>
    <span class="n">underwriting_year</span><span class="p">,</span>
    <span class="n">dev_quarter</span><span class="p">,</span>
    <span class="k">SUM</span><span class="p">(</span><span class="k">SUM</span><span class="p">(</span><span class="n">incurred_movement</span><span class="p">))</span> <span class="n">OVER</span> <span class="p">(</span>
        <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">underwriting_year</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">dev_quarter</span>
        <span class="k">ROWS</span> <span class="n">UNBOUNDED</span> <span class="k">PRECEDING</span>
    <span class="p">)</span> <span class="k">AS</span> <span class="n">cumulative_incurred</span>
<span class="k">FROM</span> <span class="n">movements</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">underwriting_year</span><span class="p">,</span> <span class="n">dev_quarter</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">underwriting_year</span><span class="p">,</span> <span class="n">dev_quarter</span><span class="p">;</span>
</code></pre></div></div>

<p>Pivot the result and you have the cumulative incurred triangle that chain ladder methods, including Mack’s (1993) distribution free model for reserve uncertainty, start from. Because the triangle is derived from movements rather than stored, you can rebuild it at any past valuation date simply by changing <code class="language-plaintext highlighter-rouge">@valuation_date</code>.</p>

<p>(The date functions above are T-SQL; Spark SQL uses <code class="language-plaintext highlighter-rouge">year()</code> and <code class="language-plaintext highlighter-rouge">quarter()</code> but the logic is identical.)</p>

<h2 id="slowly-changing-dimensions">Slowly changing dimensions</h2>

<p>Dimension attributes change: a broker is acquired, a policy is reassigned to a different class, a country’s risk rating changes. The question is whether reports should show the attribute as it is <em>now</em> or as it was <em>at the time</em>.</p>

<p>Kimball’s slowly changing dimension types give you the vocabulary:</p>

<ul>
  <li><strong>Type 1:</strong> overwrite. Reports always use the current value. Fine for corrections.</li>
  <li><strong>Type 2:</strong> add a new row with <code class="language-plaintext highlighter-rouge">valid_from</code>, <code class="language-plaintext highlighter-rouge">valid_to</code> and <code class="language-plaintext highlighter-rouge">is_current</code>. Reports can use the value at the time of each fact.</li>
</ul>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Type 2 lookup: the broker attributes that applied on each transaction date</span>
<span class="k">SELECT</span> <span class="n">f</span><span class="p">.</span><span class="o">*</span><span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">broker_group</span>
<span class="k">FROM</span> <span class="n">fact_premium_transaction</span> <span class="k">AS</span> <span class="n">f</span>
<span class="k">JOIN</span> <span class="n">dim_broker</span> <span class="k">AS</span> <span class="n">b</span>
  <span class="k">ON</span>  <span class="n">b</span><span class="p">.</span><span class="n">broker_id</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="n">broker_id</span>
  <span class="k">AND</span> <span class="n">f</span><span class="p">.</span><span class="n">transaction_date</span> <span class="o">&gt;=</span> <span class="n">b</span><span class="p">.</span><span class="n">valid_from</span>
  <span class="k">AND</span> <span class="n">f</span><span class="p">.</span><span class="n">transaction_date</span> <span class="o">&lt;</span>  <span class="n">b</span><span class="p">.</span><span class="n">valid_to</span><span class="p">;</span>
</code></pre></div></div>

<p>For most insurance hierarchies (class of business, broker group, territory), Type 2 is the safer default, because restating history without warning is exactly what makes figures fail to reconcile between quarters.</p>

<h2 id="two-kinds-of-time">Two kinds of time</h2>

<p>Insurance data has two independent timelines:</p>

<ul>
  <li><strong>Valid time:</strong> when something was true in the real world (the loss occurred on 3 March).</li>
  <li><strong>Transaction time:</strong> when we recorded it (we were told on 20 April and booked it on 22 April).</li>
</ul>

<p>Most of the time, storing transaction dates on movements is enough. But some questions, like “what did we <em>believe</em> the exposure was on 31 December, before late bordereaux arrived?”, need both. Snodgrass’s work on time oriented databases describes this <strong>bitemporal</strong> approach in detail. You do not need it everywhere, but it is worth recognising when a question needs it.</p>

<h2 id="conformed-dimensions">Conformed dimensions</h2>

<p>A dimension is <strong>conformed</strong> when every fact table uses the same version of it. If premium and claims both join to the same <code class="language-plaintext highlighter-rouge">dim_class</code>, a loss ratio by class is just two aggregations side by side. If premium uses one class mapping and claims another, every loss ratio becomes a reconciliation exercise. Conformed dimensions are the cheapest way to make figures agree across reports.</p>

<h2 id="practical-guidelines">Practical guidelines</h2>

<ol>
  <li><strong>Write the grain of every fact table</strong> in its description, and test uniqueness on it.</li>
  <li><strong>Store movements, derive totals.</strong> History comes for free.</li>
  <li><strong>Use surrogate keys</strong> for dimensions so Type 2 history works and source system changes do not break joins.</li>
  <li><strong>Conform dimensions</strong> across premium, claims and exposure.</li>
  <li><strong>Keep amounts in original currency</strong> on facts, with conversion applied in views or a semantic layer.</li>
  <li><strong>Model for the questions,</strong> and revisit the model when the questions change.</li>
</ol>

<h2 id="references">References</h2>

<ul>
  <li>Kimball, R. and Ross, M. (2013). <em>The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling</em>, 3rd edition. Wiley. (Chapter 16 covers insurance.)</li>
  <li>Snodgrass, R. T. (1999). <em>Developing Time-Oriented Database Applications in SQL</em>. Morgan Kaufmann.</li>
  <li>Mack, T. (1993). Distribution-free calculation of the standard error of chain ladder reserve estimates. <em>ASTIN Bulletin</em>, 23(2), 213 to 225.</li>
  <li>Friedland, J. (2010). <em>Estimating Unpaid Claims Using Basic Techniques</em>. Casualty Actuarial Society.</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Data Engineering" /><category term="Insurance" /><category term="Data Modelling" /><category term="Dimensional Modelling" /><category term="Star Schema" /><category term="SQL" /><category term="Reserving" /><category term="Kimball" /><summary type="html"><![CDATA[A dimensional model for premium and claims, why transaction facts beat snapshots of totals, slowly changing dimensions, and how to build reserving triangles from claim movements.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Working With Data in the London Insurance Market: A Field Guide</title><link href="https://gsehurut.github.io/blog/2026/09/14/working-with-london-market-data/" rel="alternate" type="text/html" title="Working With Data in the London Insurance Market: A Field Guide" /><published>2026-09-14T00:00:00+00:00</published><updated>2026-09-14T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2026/09/14/working-with-london-market-data</id><content type="html" xml:base="https://gsehurut.github.io/blog/2026/09/14/working-with-london-market-data/"><![CDATA[<p>If you come to the London market from another data role, the first few months can be disorienting. The data looks like ordinary transactional data, but almost every field carries a subtlety that changes the numbers. This post is the guide I would have liked when I started: the concepts that matter most, and the habits that keep analysis honest.</p>

<h2 id="how-the-market-shapes-the-data">How the market shapes the data</h2>

<p>The London market, centred on Lloyd’s and the company market around it, is built around <strong>subscription</strong>. A large risk is rarely written by a single insurer. A broker places it with a lead underwriter, who sets the terms, and then with following markets who each take a share. The same risk might be split into <strong>layers</strong> (for example the first 10m, then 15m excess of 10m), each with its own panel of insurers.</p>

<p>That structure has direct consequences for data:</p>

<ul>
  <li><strong>Line size and share.</strong> Your company’s exposure is its <em>share</em> of each layer, not the full limit. Always know whether a figure is at 100% or at your share.</li>
  <li><strong>Signed versus written lines.</strong> Underwriters write a line, but if the placement is oversubscribed the line is signed down. Premium and exposure should use signed lines once they are known.</li>
  <li><strong>One risk, many rows.</strong> A policy can have multiple sections, layers, currencies and participating entities. Getting the grain wrong is the most common cause of double counted premium.</li>
</ul>

<h2 id="open-market-versus-delegated-authority">Open market versus delegated authority</h2>

<p>Business comes in through two broad routes, and the data looks quite different for each.</p>

<p><strong>Open market</strong> business is placed risk by risk through brokers. Data usually comes from your own policy administration system and from market messaging.</p>

<p><strong>Delegated authority</strong> business is written by coverholders or managing general agents under a binding authority. You typically learn about individual risks and claims through <strong>bordereaux</strong>: periodic spreadsheets listing the risks bound and claims handled. Bordereaux quality varies enormously, which is why the Lloyd’s Coverholder Reporting Standards exist to define the expected fields. Mapping every incoming bordereau into that standard, even approximately, gives you a stable internal shape to build on.</p>

<h2 id="the-market-is-modernising-its-data">The market is modernising its data</h2>

<p>For years, much of the market’s data moved as documents and spreadsheets. Lloyd’s Blueprint Two programme aims to change that, with the <strong>Core Data Record (CDR)</strong> defining a standard set of structured data captured at placement, so that downstream processes like accounting, settlement and regulatory reporting can use the same data. The ACORD data standards have long been the reference for insurance messaging and are a useful vocabulary for internal models too.</p>

<p>For analytics teams, the direction of travel is clear: more structured data at the point of placement, and more expectation that internal models line up with market standards. Designing your internal data model with those standards in mind will save painful remapping later.</p>

<h2 id="currencies-and-rates-of-exchange">Currencies and rates of exchange</h2>

<p>London market business is written in many currencies. A single portfolio report might include USD, GBP, EUR, CAD and JPY premium. Decide, and document, three things:</p>

<ol>
  <li><strong>Original currency</strong> is the currency the contract is written in. Keep it.</li>
  <li><strong>Settlement currency</strong> is the currency the money actually moves in, which may differ.</li>
  <li><strong>Reporting currency</strong> is what you convert to for analysis, and <em>which rate</em> you use: a plan rate fixed for the year, the rate on the transaction date, or the closing rate at the reporting date.</li>
</ol>

<p>Mixing rate bases is a classic source of “the numbers don’t match finance”. Store original amounts, apply conversion in one tested place, and label every report with its rate basis.</p>

<h2 id="dates-there-are-always-more-than-you-think">Dates: there are always more than you think</h2>

<p>A single claim or policy has several dates that answer different questions:</p>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Question it answers</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Inception / expiry</td>
      <td>When is the policy on risk?</td>
    </tr>
    <tr>
      <td>Underwriting year (year of account)</td>
      <td>Which year’s book does it belong to?</td>
    </tr>
    <tr>
      <td>Loss date</td>
      <td>When did the event happen?</td>
    </tr>
    <tr>
      <td>Reported date</td>
      <td>When did we hear about it?</td>
    </tr>
    <tr>
      <td>Transaction / booked date</td>
      <td>When was the movement recorded?</td>
    </tr>
    <tr>
      <td>As at date</td>
      <td>What did the data look like at a point in time?</td>
    </tr>
  </tbody>
</table>

<p>Reserving triangles, for example, are built from underwriting or accident year against development period, using data <em>as at</em> successive valuation dates. If you cannot reconstruct what the data looked like at a past date, you cannot rebuild last quarter’s triangle to check it. That is why keeping history explicitly (snapshots or effective dated records) is not optional.</p>

<h2 id="sign-conventions-and-transaction-types">Sign conventions and transaction types</h2>

<p>Premium and claims arrive as a stream of transactions: original premium, additional and return premiums, adjustments, payments, reserve movements and recoveries. Agree a sign convention (for example, inwards premium positive, return premium negative) and enforce it at ingestion. A surprising number of reconciliation breaks come from one source treating returns as negative and another as positive with a type flag.</p>

<h2 id="practical-habits-that-help">Practical habits that help</h2>

<ul>
  <li><strong>State the basis on every number.</strong> 100% or our share; gross or net; original or converted; written or earned; as at which date.</li>
  <li><strong>Reconcile to finance early and often.</strong> If your premium does not tie to the ledger, nothing built on top will be trusted.</li>
  <li><strong>Keep reference data clean.</strong> Brokers, coverholders, insureds and countries need consistent identifiers, or aggregation and exposure reporting fall apart.</li>
  <li><strong>Talk to underwriters and operations.</strong> Most data quirks have a business reason. Someone in the room knows why that field is blank for one class.</li>
  <li><strong>Write things down.</strong> A short data dictionary with grain, basis and known issues is worth more than another dashboard.</li>
</ul>

<h2 id="closing-thought">Closing thought</h2>

<p>London market data rewards curiosity. Every odd value usually reflects something real about how a risk was placed, written or settled. The analysts who do best are the ones who learn the business behind the fields, and then build pipelines that make those rules explicit and repeatable.</p>

<h2 id="references">References</h2>

<ul>
  <li>Lloyd’s. <em>Blueprint Two</em> and the <em>Core Data Record</em>. lloyds.com</li>
  <li>Lloyd’s. <em>Coverholder Reporting Standards</em>. Lloyd’s of London.</li>
  <li>ACORD. <em>ACORD Data Standards</em>. acord.org</li>
  <li>Kimball, R. and Ross, M. (2013). <em>The Data Warehouse Toolkit</em>, 3rd edition, chapter on insurance. Wiley.</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Insurance" /><category term="Data Engineering" /><category term="London Market" /><category term="Lloyd&apos;s" /><category term="Bordereaux" /><category term="Data Quality" /><category term="Specialty Insurance" /><summary type="html"><![CDATA[Lines, layers, line sizes, bordereaux, currencies and dates: the concepts that trip up analysts new to London market data, and how to handle them.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Machine Learning in Insurance Pricing: Where It Helps, Where GLMs Still Win</title><link href="https://gsehurut.github.io/blog/2024/03/23/machine-learning-insurance/" rel="alternate" type="text/html" title="Machine Learning in Insurance Pricing: Where It Helps, Where GLMs Still Win" /><published>2024-03-23T00:00:00+00:00</published><updated>2024-03-23T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2024/03/23/machine-learning-insurance</id><content type="html" xml:base="https://gsehurut.github.io/blog/2024/03/23/machine-learning-insurance/"><![CDATA[<p>Machine learning in insurance is often described as a revolution. In practice it is more of an evolution: generalised linear models (GLMs) remain the backbone of rating in many markets, and the most useful role for more flexible models is often to <em>improve</em> and <em>challenge</em> a GLM rather than replace it. This post looks at what each approach does well, how to set up a fair comparison, and why specialty lines need extra care.</p>

<h2 id="why-glms-are-still-everywhere">Why GLMs are still everywhere</h2>

<p>GLMs became the industry standard for personal lines pricing because they fit the problem well:</p>

<ul>
  <li><strong>Right distributions.</strong> Poisson for claim counts, gamma for severity and Tweedie for pure premium match the shape of insurance data.</li>
  <li><strong>Exposure handled properly.</strong> An offset of log exposure means a six month policy is expected to have half the claims of an annual one.</li>
  <li><strong>Multiplicative structure.</strong> A log link produces rating factors that underwriters, regulators and rating engines understand directly.</li>
  <li><strong>Transparency.</strong> Every coefficient can be inspected, challenged and constrained.</li>
</ul>

<p>Goldburd, Khare, Tevet and Guller’s CAS monograph is the standard practical reference.</p>

<h2 id="where-gradient-boosting-adds-value">Where gradient boosting adds value</h2>

<p>Tree based ensembles such as gradient boosting machines (Chen and Guestrin’s XGBoost being the best known) are good at finding interactions and non-linear effects that are tedious to specify by hand in a GLM. Henckaerts et al. (2021) showed that boosted trees can produce competitive insurance tariffs and offered ways to extract interpretable insights from them.</p>

<p>Useful applications include:</p>

<ul>
  <li><strong>Finding interactions</strong> to add back into a GLM.</li>
  <li><strong>Challenger models</strong> that show where the production GLM is mispricing.</li>
  <li><strong>Risk selection and triage</strong>, where the output informs a decision rather than becoming the price directly.</li>
  <li><strong>Claims analytics</strong>, such as predicting large claim development or flagging files for review.</li>
</ul>

<h2 id="setting-up-a-fair-comparison">Setting up a fair comparison</h2>

<p>The comparison only means something if both models are trained on the same target, with the same exposure handling and the same validation scheme. A minimal example with scikit-learn:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">from</span> <span class="nn">sklearn.linear_model</span> <span class="kn">import</span> <span class="n">TweedieRegressor</span>
<span class="kn">from</span> <span class="nn">sklearn.ensemble</span> <span class="kn">import</span> <span class="n">HistGradientBoostingRegressor</span>
<span class="kn">from</span> <span class="nn">sklearn.metrics</span> <span class="kn">import</span> <span class="n">mean_tweedie_deviance</span>
<span class="kn">from</span> <span class="nn">sklearn.model_selection</span> <span class="kn">import</span> <span class="n">train_test_split</span>

<span class="c1"># X: rating factors (numeric or one-hot encoded), y: incurred loss, exposure: years at risk
</span><span class="n">X_train</span><span class="p">,</span> <span class="n">X_test</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">y_test</span><span class="p">,</span> <span class="n">e_train</span><span class="p">,</span> <span class="n">e_test</span> <span class="o">=</span> <span class="n">train_test_split</span><span class="p">(</span>
    <span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">exposure</span><span class="p">,</span> <span class="n">test_size</span><span class="o">=</span><span class="mf">0.25</span><span class="p">,</span> <span class="n">random_state</span><span class="o">=</span><span class="mi">0</span>
<span class="p">)</span>

<span class="c1"># Model pure premium (loss per unit exposure), weighting each row by its exposure
</span><span class="n">glm</span> <span class="o">=</span> <span class="n">TweedieRegressor</span><span class="p">(</span><span class="n">power</span><span class="o">=</span><span class="mf">1.5</span><span class="p">,</span> <span class="n">link</span><span class="o">=</span><span class="s">"log"</span><span class="p">,</span> <span class="n">alpha</span><span class="o">=</span><span class="mf">1e-4</span><span class="p">,</span> <span class="n">max_iter</span><span class="o">=</span><span class="mi">1000</span><span class="p">)</span>
<span class="n">glm</span><span class="p">.</span><span class="n">fit</span><span class="p">(</span><span class="n">X_train</span><span class="p">,</span> <span class="n">y_train</span> <span class="o">/</span> <span class="n">e_train</span><span class="p">,</span> <span class="n">sample_weight</span><span class="o">=</span><span class="n">e_train</span><span class="p">)</span>

<span class="n">gbm</span> <span class="o">=</span> <span class="n">HistGradientBoostingRegressor</span><span class="p">(</span><span class="n">loss</span><span class="o">=</span><span class="s">"poisson"</span><span class="p">,</span> <span class="n">max_iter</span><span class="o">=</span><span class="mi">500</span><span class="p">,</span> <span class="n">learning_rate</span><span class="o">=</span><span class="mf">0.05</span><span class="p">)</span>
<span class="n">gbm</span><span class="p">.</span><span class="n">fit</span><span class="p">(</span><span class="n">X_train</span><span class="p">,</span> <span class="n">y_train</span> <span class="o">/</span> <span class="n">e_train</span><span class="p">,</span> <span class="n">sample_weight</span><span class="o">=</span><span class="n">e_train</span><span class="p">)</span>

<span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">model</span> <span class="ow">in</span> <span class="p">[(</span><span class="s">"GLM"</span><span class="p">,</span> <span class="n">glm</span><span class="p">),</span> <span class="p">(</span><span class="s">"GBM"</span><span class="p">,</span> <span class="n">gbm</span><span class="p">)]:</span>
    <span class="n">pred</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="n">X_test</span><span class="p">)</span>
    <span class="n">dev</span> <span class="o">=</span> <span class="n">mean_tweedie_deviance</span><span class="p">(</span><span class="n">y_test</span> <span class="o">/</span> <span class="n">e_test</span><span class="p">,</span> <span class="n">pred</span><span class="p">,</span> <span class="n">sample_weight</span><span class="o">=</span><span class="n">e_test</span><span class="p">,</span> <span class="n">power</span><span class="o">=</span><span class="mf">1.5</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s">: Tweedie deviance </span><span class="si">{</span><span class="n">dev</span><span class="si">:</span><span class="p">.</span><span class="mi">4</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Two things to note. First, use a deviance that matches the data (Tweedie or Poisson), not mean squared error, which is dominated by a few large claims. Second, look beyond one summary metric: <strong>lift charts</strong> and <strong>double lift charts</strong> comparing the two models by predicted decile are what pricing teams actually use to decide whether a model is better.</p>

<h2 id="explainability-is-not-optional">Explainability is not optional</h2>

<p>Pricing decisions affect customers and are scrutinised by regulators. In the UK, the FCA’s general insurance pricing practices rules (PS21/5) are a reminder that how prices are set matters, not just whether they are accurate. Any model used in pricing needs to be explainable to underwriters, compliance and, where relevant, customers.</p>

<p>Tools that help:</p>

<ul>
  <li><strong>Partial dependence and accumulated local effects</strong> plots show how predictions change with a single factor.</li>
  <li><strong>SHAP values</strong> (Lundberg and Lee, 2017) attribute each prediction to its inputs and can be aggregated into global importance.</li>
  <li><strong>Monotonic constraints</strong>, supported by most boosting libraries, enforce business logic such as “higher sum insured should not reduce the price”.</li>
</ul>

<h2 id="specialty-lines-are-different">Specialty lines are different</h2>

<p>Most published work on machine learning pricing uses personal lines data with hundreds of thousands of policies. Specialty and London market business looks very different:</p>

<ul>
  <li><strong>Small data.</strong> A marine or political risk book may have a few thousand policies and very few large losses. Flexible models overfit easily.</li>
  <li><strong>Heterogeneous risks.</strong> Every risk is partly bespoke, and much of the information sits in underwriter judgement and submission documents rather than structured fields.</li>
  <li><strong>Heavy tails.</strong> Results are driven by rare, large losses that a model fitted to the body of the distribution will not capture.</li>
  <li><strong>Rating structures.</strong> Pricing is often built from exposure based rates, increased limit factors and expert adjustments rather than a single statistical model.</li>
</ul>

<p>In this setting, the most valuable “machine learning” is often modest: well engineered features, credibility weighting between portfolio experience and market benchmarks, GLMs with sensible constraints, and good tooling that lets underwriters see the drivers behind a technical price. Wüthrich and Merz (2023) cover the statistical foundations that link classical actuarial models with modern machine learning, and it is a good reminder that the two traditions have more in common than the hype suggests.</p>

<h2 id="practical-advice">Practical advice</h2>

<ol>
  <li><strong>Start with a well built GLM.</strong> It is the benchmark everything else must beat.</li>
  <li><strong>Use the right loss function</strong> for counts, severity or pure premium.</li>
  <li><strong>Handle exposure explicitly</strong> through offsets or weights.</li>
  <li><strong>Validate out of time</strong>, not just out of sample. Insurance data drifts with the cycle.</li>
  <li><strong>Compare with lift charts</strong>, not just a single metric.</li>
  <li><strong>Explain everything</strong> you put in front of an underwriter or regulator.</li>
  <li><strong>Respect small data.</strong> In specialty lines, credibility and judgement are features, not failures.</li>
</ol>

<h2 id="references">References</h2>

<ul>
  <li>Goldburd, M., Khare, A., Tevet, D. and Guller, D. (2020). <em>Generalized Linear Models for Insurance Rating</em>, 2nd edition. CAS Monograph No. 5. Casualty Actuarial Society.</li>
  <li>Wüthrich, M. V. and Merz, M. (2023). <em>Statistical Foundations of Actuarial Learning and its Applications</em>. Springer (open access).</li>
  <li>Chen, T. and Guestrin, C. (2016). XGBoost: a scalable tree boosting system. <em>Proceedings of the 22nd ACM SIGKDD Conference</em>, 785 to 794.</li>
  <li>Henckaerts, R., Côté, M.-P., Antonio, K. and Verbelen, R. (2021). Boosting insights in insurance tariff plans with tree-based machine learning methods. <em>North American Actuarial Journal</em>, 25(2), 255 to 285.</li>
  <li>Lundberg, S. M. and Lee, S.-I. (2017). A unified approach to interpreting model predictions. <em>Advances in Neural Information Processing Systems 30</em>.</li>
  <li>Financial Conduct Authority (2021). <em>PS21/5: General insurance pricing practices</em>.</li>
  <li>scikit-learn documentation. <em>Tweedie regression on insurance claims</em>. scikit-learn.org</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Machine Learning" /><category term="Actuarial" /><category term="Machine Learning" /><category term="GLM" /><category term="Gradient Boosting" /><category term="Pricing" /><category term="Explainability" /><category term="Python" /><summary type="html"><![CDATA[A practical view of GLMs and gradient boosting for insurance pricing: Tweedie losses, exposure offsets, explainability, and why specialty lines need a different approach.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Data Engineering for Insurance Analytics: Principles That Survive Contact With Bordereaux</title><link href="https://gsehurut.github.io/blog/2024/03/22/data-engineering-insurance/" rel="alternate" type="text/html" title="Data Engineering for Insurance Analytics: Principles That Survive Contact With Bordereaux" /><published>2024-03-22T00:00:00+00:00</published><updated>2024-03-22T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2024/03/22/data-engineering-insurance</id><content type="html" xml:base="https://gsehurut.github.io/blog/2024/03/22/data-engineering-insurance/"><![CDATA[<p>Insurance data is rarely “big” in the way web data is big. It is <em>awkward</em>. It arrives as spreadsheets from coverholders, as extracts from policy administration systems, as bordereaux in a dozen slightly different layouts, and as manual adjustments that someone made for a good reason three years ago. The hard part is not volume; it is making sure the number an actuary or underwriter sees is right, explainable and reproducible.</p>

<p>These are the principles I have found matter most.</p>

<h2 id="1-define-the-grain-before-writing-any-code">1. Define the grain before writing any code</h2>

<p>The grain of a table is what one row represents. “One row per policy” sounds clear until you meet layered placements, endorsements, multiple sections, multiple currencies and several participating syndicates on the same risk. Many reporting errors come from joining two tables at different grains and silently duplicating premium.</p>

<p>Write the grain down for every table, for example: <em>one row per policy section, per layer, per transaction, per currency</em>. Then enforce it with a uniqueness test on those columns.</p>

<h2 id="2-make-every-load-idempotent">2. Make every load idempotent</h2>

<p>An idempotent pipeline produces the same result whether it runs once or five times. This matters because pipelines fail halfway, get rerun by hand, and receive corrected files for a period that has already been loaded.</p>

<p>Practical patterns:</p>

<ul>
  <li>Load by <strong>partition</strong> (for example by reporting period and source) and replace the whole partition, rather than appending.</li>
  <li>Use <code class="language-plaintext highlighter-rouge">MERGE</code> on a natural key instead of blind inserts.</li>
  <li>Keep raw files immutable and reprocess from them, so a bug fix can be replayed over history.</li>
</ul>

<h2 id="3-validate-at-the-boundary">3. Validate at the boundary</h2>

<p>Bad data is cheapest to catch the moment it arrives. A bordereau that is missing its inception dates, or has premium in the wrong currency, should fail loudly before it reaches a dashboard.</p>

<p>A lightweight validation step in pandas goes a long way:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">pandas</span> <span class="k">as</span> <span class="n">pd</span>

<span class="n">REQUIRED</span> <span class="o">=</span> <span class="p">[</span><span class="s">"policy_ref"</span><span class="p">,</span> <span class="s">"inception_date"</span><span class="p">,</span> <span class="s">"expiry_date"</span><span class="p">,</span> <span class="s">"currency"</span><span class="p">,</span> <span class="s">"gross_premium"</span><span class="p">]</span>
<span class="n">VALID_CCY</span> <span class="o">=</span> <span class="p">{</span><span class="s">"GBP"</span><span class="p">,</span> <span class="s">"USD"</span><span class="p">,</span> <span class="s">"EUR"</span><span class="p">,</span> <span class="s">"CAD"</span><span class="p">,</span> <span class="s">"AUD"</span><span class="p">,</span> <span class="s">"JPY"</span><span class="p">}</span>

<span class="k">def</span> <span class="nf">validate_bordereau</span><span class="p">(</span><span class="n">df</span><span class="p">:</span> <span class="n">pd</span><span class="p">.</span><span class="n">DataFrame</span><span class="p">,</span> <span class="n">source</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">pd</span><span class="p">.</span><span class="n">DataFrame</span><span class="p">:</span>
    <span class="n">errors</span> <span class="o">=</span> <span class="p">[]</span>

    <span class="n">missing</span> <span class="o">=</span> <span class="p">[</span><span class="n">c</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">REQUIRED</span> <span class="k">if</span> <span class="n">c</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">df</span><span class="p">.</span><span class="n">columns</span><span class="p">]</span>
    <span class="k">if</span> <span class="n">missing</span><span class="p">:</span>
        <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">source</span><span class="si">}</span><span class="s">: missing columns </span><span class="si">{</span><span class="n">missing</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">df</span><span class="p">[</span><span class="s">"policy_ref"</span><span class="p">].</span><span class="n">isna</span><span class="p">().</span><span class="nb">any</span><span class="p">():</span>
        <span class="n">errors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="s">"blank policy_ref"</span><span class="p">)</span>

    <span class="n">dupes</span> <span class="o">=</span> <span class="n">df</span><span class="p">.</span><span class="n">duplicated</span><span class="p">(</span><span class="n">subset</span><span class="o">=</span><span class="p">[</span><span class="s">"policy_ref"</span><span class="p">,</span> <span class="s">"section"</span><span class="p">],</span> <span class="n">keep</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">dupes</span><span class="p">.</span><span class="nb">any</span><span class="p">():</span>
        <span class="n">errors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">dupes</span><span class="p">.</span><span class="nb">sum</span><span class="p">()</span><span class="si">}</span><span class="s"> duplicate policy/section rows"</span><span class="p">)</span>

    <span class="n">bad_dates</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">"expiry_date"</span><span class="p">]</span> <span class="o">&lt;=</span> <span class="n">df</span><span class="p">[</span><span class="s">"inception_date"</span><span class="p">]</span>
    <span class="k">if</span> <span class="n">bad_dates</span><span class="p">.</span><span class="nb">any</span><span class="p">():</span>
        <span class="n">errors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">bad_dates</span><span class="p">.</span><span class="nb">sum</span><span class="p">()</span><span class="si">}</span><span class="s"> rows with expiry on or before inception"</span><span class="p">)</span>

    <span class="n">bad_ccy</span> <span class="o">=</span> <span class="o">~</span><span class="n">df</span><span class="p">[</span><span class="s">"currency"</span><span class="p">].</span><span class="n">isin</span><span class="p">(</span><span class="n">VALID_CCY</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">bad_ccy</span><span class="p">.</span><span class="nb">any</span><span class="p">():</span>
        <span class="n">errors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s">"unknown currencies: </span><span class="si">{</span><span class="nb">sorted</span><span class="p">(</span><span class="n">df</span><span class="p">.</span><span class="n">loc</span><span class="p">[</span><span class="n">bad_ccy</span><span class="p">,</span> <span class="s">'currency'</span><span class="p">].</span><span class="n">unique</span><span class="p">())</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">errors</span><span class="p">:</span>
        <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">source</span><span class="si">}</span><span class="s">: "</span> <span class="o">+</span> <span class="s">"; "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">errors</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">df</span>
</code></pre></div></div>

<p>Libraries such as pandera or Great Expectations formalise this with declarative schemas and readable reports, and are worth adopting once the number of checks grows. The principle is the same: reject or quarantine bad input with a clear message, never silently coerce it.</p>

<p>For delegated authority business, the Lloyd’s Coverholder Reporting Standards define the expected fields for risk and claims bordereaux. Even where a coverholder does not follow them exactly, mapping incoming files to that standard gives you a stable internal shape.</p>

<h2 id="4-reconcile-to-something-the-business-trusts">4. Reconcile to something the business trusts</h2>

<p>Validation checks that data is internally consistent. Reconciliation checks that it agrees with the outside world. Every important dataset should reconcile to a known control total: written premium to the finance ledger, paid claims to the claims system, policy counts to the underwriting system.</p>

<p>Automate the reconciliation, store the result alongside the data, and show it on the report. A dashboard that says “reconciles to ledger within 0.1%” earns far more trust than one that does not mention it.</p>

<h2 id="5-keep-history-explicitly">5. Keep history explicitly</h2>

<p>Actuarial work constantly asks “what did we know at the time?”. Reserving uses data as at each quarter end; pricing reviews compare this year’s assumptions with last year’s. If your tables only hold the current state, those questions become impossible to answer.</p>

<p>Two common approaches:</p>

<ul>
  <li><strong>Snapshots:</strong> store a full copy of key tables at each reporting date. Simple and very easy to query.</li>
  <li><strong>Slowly changing dimensions (Type 2):</strong> keep a row per version with <code class="language-plaintext highlighter-rouge">valid_from</code> and <code class="language-plaintext highlighter-rouge">valid_to</code> dates. More compact, but queries need care.</li>
</ul>

<p>The data modelling post goes deeper on this, including the difference between when something happened and when we recorded it.</p>

<h2 id="6-separate-business-rules-from-plumbing">6. Separate business rules from plumbing</h2>

<p>Rules such as “how to convert to settlement currency”, “which rate of exchange to use” or “how to allocate premium across sections” should live in one well tested place, not be repeated in five SQL scripts and three notebooks. Put them in pure functions or in a single curated layer, write unit tests for them, and have everything else call that.</p>

<h2 id="7-make-lineage-visible">7. Make lineage visible</h2>

<p>When an underwriter asks why a number changed, you need to be able to trace it back: which file, which load, which version of the transformation. At minimum, stamp every row with the source file, the load timestamp and the pipeline version. Platform tools can add richer lineage on top, but these three columns answer most questions.</p>

<h2 id="8-treat-pipelines-as-software">8. Treat pipelines as software</h2>

<p>Version control, code review, automated tests and CI/CD are not just for application developers. A pricing dataset that feeds underwriting decisions deserves the same discipline as a production web service. Reis and Housley (2022) describe this as treating data as a product with owners, contracts and service levels, which is a useful framing for actuarial data.</p>

<h2 id="a-short-checklist">A short checklist</h2>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Grain written down and enforced with a uniqueness test</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Loads are idempotent and can be rerun safely</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Inputs validated with clear, actionable error messages</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Outputs reconciled to a trusted control total</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />History kept so “as at” questions can be answered</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Business rules in one tested place</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Source, load time and version recorded on every row</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Code reviewed, tested and deployed through CI</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li>Reis, J. and Housley, M. (2022). <em>Fundamentals of Data Engineering</em>. O’Reilly Media.</li>
  <li>Kleppmann, M. (2017). <em>Designing Data-Intensive Applications</em>. O’Reilly Media.</li>
  <li>Kimball, R. and Ross, M. (2013). <em>The Data Warehouse Toolkit</em>, 3rd edition. Wiley.</li>
  <li>Lloyd’s. <em>Coverholder Reporting Standards</em>. Lloyd’s of London.</li>
  <li>pandera documentation. pandera.readthedocs.io</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Data Engineering" /><category term="Insurance" /><category term="Data Engineering" /><category term="ETL" /><category term="Data Quality" /><category term="Bordereaux" /><category term="Python" /><category term="SQL" /><summary type="html"><![CDATA[Idempotent loads, explicit grain, validation at the boundary, reconciliation and lineage: the data engineering habits that make actuarial and underwriting numbers trustworthy.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Copulas for Insurance Dependence: A Practical Guide and Its Pitfalls</title><link href="https://gsehurut.github.io/blog/2024/03/21/copula-dependency-modeling/" rel="alternate" type="text/html" title="Copulas for Insurance Dependence: A Practical Guide and Its Pitfalls" /><published>2024-03-21T00:00:00+00:00</published><updated>2024-03-21T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2024/03/21/copula-dependency-modeling</id><content type="html" xml:base="https://gsehurut.github.io/blog/2024/03/21/copula-dependency-modeling/"><![CDATA[<p>The previous post simulated losses as if every line of business were independent. In reality, a hurricane hits property and marine cargo together, a recession hits credit and political risk together, and inflation pushes up claims everywhere at once. Copulas are a standard way to add that dependence to a simulation without giving up the marginal distributions you have already fitted for each line.</p>

<h2 id="why-correlation-alone-is-not-enough">Why correlation alone is not enough</h2>

<p>Linear (Pearson) correlation is a single number, and many very different joint behaviours share the same correlation. Embrechts, McNeil and Straumann (2002) set out the problems clearly:</p>

<ul>
  <li>Correlation only fully describes dependence for elliptical distributions such as the multivariate normal.</li>
  <li>It is not invariant to non-linear transformations, so the correlation between losses differs from the correlation between their logarithms.</li>
  <li>For a given pair of marginals, not every correlation between -1 and 1 is achievable.</li>
  <li>Most importantly for insurance, two models with the same correlation can have completely different behaviour in the <strong>tail</strong>, which is exactly where capital and reinsurance decisions are made.</li>
</ul>

<h2 id="what-a-copula-is">What a copula is</h2>

<p>Sklar’s theorem (1959) says that any joint distribution can be split into its marginal distributions and a <strong>copula</strong> that describes how they move together. In practice that gives a clean workflow:</p>

<ol>
  <li>Fit each line’s loss distribution on its own (the marginals).</li>
  <li>Choose and calibrate a copula for the dependence.</li>
  <li>Simulate correlated uniforms from the copula and push them through each marginal’s inverse CDF.</li>
</ol>

<p>Rank based measures such as Kendall’s tau and Spearman’s rho depend only on the copula, not the marginals, which makes them the natural tools for calibration.</p>

<h2 id="gaussian-versus-student-t-the-tail-matters">Gaussian versus Student t: the tail matters</h2>

<p>The two copulas you meet most often are the Gaussian and the Student t. With the same correlation parameter they look similar in the middle of the distribution, but they behave very differently in the extremes.</p>

<p>The <strong>coefficient of upper tail dependence</strong> measures the probability that one variable is extreme given that the other is extreme, in the limit. For the Gaussian copula it is <strong>zero</strong> for any correlation below 1: extremes eventually become independent. For the t copula it is positive and grows as the degrees of freedom fall. McNeil, Frey and Embrechts (2015) give the formulas and derivations.</p>

<p>That single fact explains why a Gaussian copula can understate the chance of two lines having a bad year together.</p>

<h2 id="simulating-both-in-python">Simulating both in Python</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">from</span> <span class="nn">scipy</span> <span class="kn">import</span> <span class="n">stats</span>

<span class="n">rng</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">random</span><span class="p">.</span><span class="n">default_rng</span><span class="p">(</span><span class="n">seed</span><span class="o">=</span><span class="mi">7</span><span class="p">)</span>
<span class="n">n</span> <span class="o">=</span> <span class="mi">200_000</span>
<span class="n">rho</span> <span class="o">=</span> <span class="mf">0.5</span>
<span class="n">corr</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([[</span><span class="mf">1.0</span><span class="p">,</span> <span class="n">rho</span><span class="p">],</span> <span class="p">[</span><span class="n">rho</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">]])</span>
<span class="n">chol</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">cholesky</span><span class="p">(</span><span class="n">corr</span><span class="p">)</span>

<span class="c1"># Gaussian copula: correlated normals mapped to uniforms
</span><span class="n">z</span> <span class="o">=</span> <span class="n">rng</span><span class="p">.</span><span class="n">standard_normal</span><span class="p">((</span><span class="n">n</span><span class="p">,</span> <span class="mi">2</span><span class="p">))</span> <span class="o">@</span> <span class="n">chol</span><span class="p">.</span><span class="n">T</span>
<span class="n">u_gauss</span> <span class="o">=</span> <span class="n">stats</span><span class="p">.</span><span class="n">norm</span><span class="p">.</span><span class="n">cdf</span><span class="p">(</span><span class="n">z</span><span class="p">)</span>

<span class="c1"># Student t copula with 4 degrees of freedom:
# same correlated normals, divided by a shared chi-square mixing variable
</span><span class="n">nu</span> <span class="o">=</span> <span class="mi">4</span>
<span class="n">w</span> <span class="o">=</span> <span class="n">rng</span><span class="p">.</span><span class="n">chisquare</span><span class="p">(</span><span class="n">nu</span><span class="p">,</span> <span class="n">size</span><span class="o">=</span><span class="p">(</span><span class="n">n</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span> <span class="o">/</span> <span class="n">nu</span>
<span class="n">t_samples</span> <span class="o">=</span> <span class="n">z</span> <span class="o">/</span> <span class="n">np</span><span class="p">.</span><span class="n">sqrt</span><span class="p">(</span><span class="n">w</span><span class="p">)</span>
<span class="n">u_t</span> <span class="o">=</span> <span class="n">stats</span><span class="p">.</span><span class="n">t</span><span class="p">.</span><span class="n">cdf</span><span class="p">(</span><span class="n">t_samples</span><span class="p">,</span> <span class="n">df</span><span class="o">=</span><span class="n">nu</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">joint_exceedance</span><span class="p">(</span><span class="n">u</span><span class="p">,</span> <span class="n">q</span><span class="p">):</span>
    <span class="s">"""Probability that both variables exceed their q-quantile."""</span>
    <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="n">mean</span><span class="p">((</span><span class="n">u</span><span class="p">[:,</span> <span class="mi">0</span><span class="p">]</span> <span class="o">&gt;</span> <span class="n">q</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">u</span><span class="p">[:,</span> <span class="mi">1</span><span class="p">]</span> <span class="o">&gt;</span> <span class="n">q</span><span class="p">))</span>

<span class="k">for</span> <span class="n">q</span> <span class="ow">in</span> <span class="p">(</span><span class="mf">0.9</span><span class="p">,</span> <span class="mf">0.99</span><span class="p">,</span> <span class="mf">0.995</span><span class="p">):</span>
    <span class="k">print</span><span class="p">(</span>
        <span class="sa">f</span><span class="s">"q=</span><span class="si">{</span><span class="n">q</span><span class="si">}</span><span class="s">: independent </span><span class="si">{</span><span class="p">(</span><span class="mi">1</span><span class="o">-</span><span class="n">q</span><span class="p">)</span><span class="o">**</span><span class="mi">2</span><span class="si">:</span><span class="p">.</span><span class="mi">5</span><span class="n">f</span><span class="si">}</span><span class="s">  "</span>
        <span class="sa">f</span><span class="s">"gaussian </span><span class="si">{</span><span class="n">joint_exceedance</span><span class="p">(</span><span class="n">u_gauss</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span><span class="si">:</span><span class="p">.</span><span class="mi">5</span><span class="n">f</span><span class="si">}</span><span class="s">  "</span>
        <span class="sa">f</span><span class="s">"t(4) </span><span class="si">{</span><span class="n">joint_exceedance</span><span class="p">(</span><span class="n">u_t</span><span class="p">,</span> <span class="n">q</span><span class="p">)</span><span class="si">:</span><span class="p">.</span><span class="mi">5</span><span class="n">f</span><span class="si">}</span><span class="s">"</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>Run it and you will see the three columns agree reasonably at the 90th percentile, then diverge sharply at 99.5%, with the t copula producing several times more joint extremes than the Gaussian. Same correlation, very different capital.</p>

<h2 id="turning-uniforms-into-losses">Turning uniforms into losses</h2>

<p>Once you have dependent uniforms, apply each line’s fitted marginal:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Marginals fitted separately for each line (illustrative parameters)
</span><span class="n">property_cat</span> <span class="o">=</span> <span class="n">stats</span><span class="p">.</span><span class="n">lognorm</span><span class="p">(</span><span class="n">s</span><span class="o">=</span><span class="mf">1.4</span><span class="p">,</span> <span class="n">scale</span><span class="o">=</span><span class="mf">8e6</span><span class="p">)</span>
<span class="n">marine_cargo</span> <span class="o">=</span> <span class="n">stats</span><span class="p">.</span><span class="n">genpareto</span><span class="p">(</span><span class="n">c</span><span class="o">=</span><span class="mf">0.3</span><span class="p">,</span> <span class="n">scale</span><span class="o">=</span><span class="mf">2e6</span><span class="p">)</span>

<span class="n">losses_property</span> <span class="o">=</span> <span class="n">property_cat</span><span class="p">.</span><span class="n">ppf</span><span class="p">(</span><span class="n">u_t</span><span class="p">[:,</span> <span class="mi">0</span><span class="p">])</span>
<span class="n">losses_marine</span> <span class="o">=</span> <span class="n">marine_cargo</span><span class="p">.</span><span class="n">ppf</span><span class="p">(</span><span class="n">u_t</span><span class="p">[:,</span> <span class="mi">1</span><span class="p">])</span>
<span class="n">portfolio</span> <span class="o">=</span> <span class="n">losses_property</span> <span class="o">+</span> <span class="n">losses_marine</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"1-in-200 portfolio loss: </span><span class="si">{</span><span class="n">np</span><span class="p">.</span><span class="n">quantile</span><span class="p">(</span><span class="n">portfolio</span><span class="p">,</span> <span class="mf">0.995</span><span class="p">)</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="p">.</span><span class="mi">1</span><span class="n">f</span><span class="si">}</span><span class="s">m"</span><span class="p">)</span>
</code></pre></div></div>

<p>Swap <code class="language-plaintext highlighter-rouge">u_t</code> for <code class="language-plaintext highlighter-rouge">u_gauss</code> and compare. The difference is a direct measure of how much your choice of copula is driving the tail.</p>

<h2 id="calibrating-the-dependence">Calibrating the dependence</h2>

<p>For elliptical copulas there is a convenient link between Kendall’s tau and the correlation parameter: rho = sin(pi * tau / 2). Calibrating via rank correlation is more robust than using Pearson correlation on raw losses:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">rho_from_kendall</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">):</span>
    <span class="n">tau</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">stats</span><span class="p">.</span><span class="n">kendalltau</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="n">sin</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">tau</span> <span class="o">/</span> <span class="mi">2</span><span class="p">)</span>
</code></pre></div></div>

<p>The degrees of freedom of a t copula are harder to estimate and usually need maximum likelihood on pseudo-observations (ranks scaled into the unit interval). With the short histories typical of specialty lines, expect the estimate to be uncertain and treat it as a judgement to be documented and stress tested, not a fact.</p>

<h2 id="pitfalls-to-take-seriously">Pitfalls to take seriously</h2>

<ul>
  <li><strong>Data is thin in the tail.</strong> You are trying to estimate joint extreme behaviour from a handful of joint extreme years. Expert judgement and scenarios should inform the parameters, and sensitivity testing should show how much they matter.</li>
  <li><strong>The Gaussian copula’s reputation.</strong> Li’s (2000) Gaussian copula model for default correlation became infamous after the financial crisis, largely because its lack of tail dependence and the over-confidence in its calibration were not appreciated. Mikosch (2006) is a sharp critique of how copulas are sometimes oversold.</li>
  <li><strong>Dependence is not constant.</strong> Lines can look independent in normal years and move together in stress. A single copula fitted to all years can hide this.</li>
  <li><strong>Higher dimensions.</strong> A single t copula imposes the same tail behaviour on every pair of lines. Vine copulas (Joe, 2014) allow more flexible pairwise structures at the cost of more parameters to justify.</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<p>Copulas are a useful tool for one specific job: combining well understood marginals with a defensible dependence structure. They are not a substitute for thinking about <em>why</em> lines are dependent. In practice the most valuable output is often the comparison itself: showing underwriters and management how much the tail moves when dependence assumptions change.</p>

<h2 id="references">References</h2>

<ul>
  <li>Sklar, A. (1959). Fonctions de répartition à n dimensions et leurs marges. <em>Publications de l’Institut de Statistique de l’Université de Paris</em>, 8, 229 to 231.</li>
  <li>Embrechts, P., McNeil, A. and Straumann, D. (2002). Correlation and dependence in risk management: properties and pitfalls. In <em>Risk Management: Value at Risk and Beyond</em>, Cambridge University Press.</li>
  <li>McNeil, A. J., Frey, R. and Embrechts, P. (2015). <em>Quantitative Risk Management: Concepts, Techniques and Tools</em>, revised edition. Princeton University Press.</li>
  <li>Nelsen, R. B. (2006). <em>An Introduction to Copulas</em>, 2nd edition. Springer.</li>
  <li>Joe, H. (2014). <em>Dependence Modeling with Copulas</em>. Chapman and Hall/CRC.</li>
  <li>Li, D. X. (2000). On default correlation: a copula function approach. <em>Journal of Fixed Income</em>, 9(4), 43 to 54.</li>
  <li>Mikosch, T. (2006). Copulas: tales and facts. <em>Extremes</em>, 9, 3 to 20.</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Actuarial" /><category term="Risk Modelling" /><category term="Copulas" /><category term="Dependence" /><category term="Tail Risk" /><category term="Python" /><category term="SciPy" /><summary type="html"><![CDATA[Why correlation is not enough for tail risk, how Gaussian and t copulas differ, how to simulate and calibrate them in Python, and the warnings every analyst should know.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Monte Carlo Loss Simulation for Specialty Insurance, From First Principles</title><link href="https://gsehurut.github.io/blog/2024/03/20/loss-simulation-monte-carlo/" rel="alternate" type="text/html" title="Monte Carlo Loss Simulation for Specialty Insurance, From First Principles" /><published>2024-03-20T00:00:00+00:00</published><updated>2024-03-20T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2024/03/20/loss-simulation-monte-carlo</id><content type="html" xml:base="https://gsehurut.github.io/blog/2024/03/20/loss-simulation-monte-carlo/"><![CDATA[<p>Monte Carlo simulation is the workhorse behind capital models, reinsurance pricing and catastrophe analytics. The idea is simple: if we can describe how many losses happen and how big each one is, we can generate thousands of possible years and read off whatever statistic we need. The details are where most mistakes happen, so this post builds a small but correct simulation and points out the traps along the way.</p>

<h2 id="the-collective-risk-model">The collective risk model</h2>

<p>The standard starting point is the collective risk model. Annual aggregate loss is</p>

<p><strong>S = X<sub>1</sub> + X<sub>2</sub> + … + X<sub>N</sub></strong></p>

<p>where <em>N</em> is the number of losses in the year and <em>X<sub>1</sub>, X<sub>2</sub>, …</em> are individual loss sizes, assumed independent of each other and of <em>N</em>. This is covered in depth in Klugman, Panjer and Willmot’s <em>Loss Models</em>, and it is the right mental model even when the production version is far more elaborate.</p>

<p>Two modelling choices matter most:</p>

<ul>
  <li><strong>Frequency.</strong> Poisson is the default, but specialty data is usually <em>overdispersed</em> (variance greater than mean) because the underlying rate itself varies from year to year. A negative binomial captures that.</li>
  <li><strong>Severity.</strong> Large commercial losses are heavy tailed. A lognormal is a reasonable body; a Pareto or generalised Pareto is often better above a threshold.</li>
</ul>

<h2 id="a-minimal-vectorised-simulation">A minimal, vectorised simulation</h2>

<p>The code below simulates 100,000 years. It avoids a Python loop over years by drawing every loss at once and then summing them back into their years with <code class="language-plaintext highlighter-rouge">np.add.at</code>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>

<span class="n">rng</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">random</span><span class="p">.</span><span class="n">default_rng</span><span class="p">(</span><span class="n">seed</span><span class="o">=</span><span class="mi">2024</span><span class="p">)</span>  <span class="c1"># always seed, so results are reproducible
</span>
<span class="n">n_years</span> <span class="o">=</span> <span class="mi">100_000</span>

<span class="c1"># Frequency: negative binomial with mean 12 and variance 24 (overdispersed)
</span><span class="n">mean_n</span><span class="p">,</span> <span class="n">var_n</span> <span class="o">=</span> <span class="mf">12.0</span><span class="p">,</span> <span class="mf">24.0</span>
<span class="n">p</span> <span class="o">=</span> <span class="n">mean_n</span> <span class="o">/</span> <span class="n">var_n</span>
<span class="n">r</span> <span class="o">=</span> <span class="n">mean_n</span> <span class="o">*</span> <span class="n">p</span> <span class="o">/</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="n">p</span><span class="p">)</span>
<span class="n">counts</span> <span class="o">=</span> <span class="n">rng</span><span class="p">.</span><span class="n">negative_binomial</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="n">p</span><span class="p">,</span> <span class="n">size</span><span class="o">=</span><span class="n">n_years</span><span class="p">)</span>

<span class="c1"># Severity: lognormal body with a mean of about 1m
</span><span class="n">mu</span><span class="p">,</span> <span class="n">sigma</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="mi">1_000_000</span><span class="p">)</span> <span class="o">-</span> <span class="mf">0.5</span> <span class="o">*</span> <span class="mf">1.2</span><span class="o">**</span><span class="mi">2</span><span class="p">,</span> <span class="mf">1.2</span>
<span class="n">losses</span> <span class="o">=</span> <span class="n">rng</span><span class="p">.</span><span class="n">lognormal</span><span class="p">(</span><span class="n">mu</span><span class="p">,</span> <span class="n">sigma</span><span class="p">,</span> <span class="n">size</span><span class="o">=</span><span class="n">counts</span><span class="p">.</span><span class="nb">sum</span><span class="p">())</span>

<span class="c1"># Map every loss back to the year it belongs to
</span><span class="n">year_index</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">repeat</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="n">n_years</span><span class="p">),</span> <span class="n">counts</span><span class="p">)</span>

<span class="c1"># Gross annual aggregate loss
</span><span class="n">gross</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_years</span><span class="p">)</span>
<span class="n">np</span><span class="p">.</span><span class="n">add</span><span class="p">.</span><span class="n">at</span><span class="p">(</span><span class="n">gross</span><span class="p">,</span> <span class="n">year_index</span><span class="p">,</span> <span class="n">losses</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="applying-an-excess-of-loss-layer">Applying an excess of loss layer</h2>

<p>A per-risk excess of loss layer “5m xs 2m” pays the part of each loss between 2m and 7m. Applying it <em>per loss before aggregating</em> is the step people most often get wrong; applying it to the annual total gives an aggregate stop loss instead, which is a different contract.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">attachment</span><span class="p">,</span> <span class="n">limit</span> <span class="o">=</span> <span class="mi">2_000_000</span><span class="p">,</span> <span class="mi">5_000_000</span>

<span class="n">ceded_per_loss</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">clip</span><span class="p">(</span><span class="n">losses</span> <span class="o">-</span> <span class="n">attachment</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">limit</span><span class="p">)</span>

<span class="n">ceded</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_years</span><span class="p">)</span>
<span class="n">np</span><span class="p">.</span><span class="n">add</span><span class="p">.</span><span class="n">at</span><span class="p">(</span><span class="n">ceded</span><span class="p">,</span> <span class="n">year_index</span><span class="p">,</span> <span class="n">ceded_per_loss</span><span class="p">)</span>

<span class="n">net</span> <span class="o">=</span> <span class="n">gross</span> <span class="o">-</span> <span class="n">ceded</span>
</code></pre></div></div>

<p>If the layer has an annual aggregate limit or reinstatements, apply those to <code class="language-plaintext highlighter-rouge">ceded</code> per year after this step.</p>

<h2 id="reading-the-results-aep-versus-oep">Reading the results: AEP versus OEP</h2>

<p>Two exceedance curves come out of a simulation like this, and they answer different questions:</p>

<ul>
  <li><strong>AEP (aggregate exceedance probability):</strong> the probability that <em>total</em> annual loss exceeds a level. Relevant for capital and aggregate covers.</li>
  <li><strong>OEP (occurrence exceedance probability):</strong> the probability that the <em>largest single</em> loss in a year exceeds a level. Relevant for per-occurrence reinsurance.</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Largest single loss per year (years with no losses have a maximum of 0)
</span><span class="n">max_loss</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_years</span><span class="p">)</span>
<span class="n">np</span><span class="p">.</span><span class="n">maximum</span><span class="p">.</span><span class="n">at</span><span class="p">(</span><span class="n">max_loss</span><span class="p">,</span> <span class="n">year_index</span><span class="p">,</span> <span class="n">losses</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">return_period_loss</span><span class="p">(</span><span class="n">annual_values</span><span class="p">,</span> <span class="n">return_period</span><span class="p">):</span>
    <span class="s">"""Loss level exceeded on average once every `return_period` years."""</span>
    <span class="k">return</span> <span class="n">np</span><span class="p">.</span><span class="n">quantile</span><span class="p">(</span><span class="n">annual_values</span><span class="p">,</span> <span class="mi">1</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">/</span> <span class="n">return_period</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">tvar</span><span class="p">(</span><span class="n">annual_values</span><span class="p">,</span> <span class="n">level</span><span class="p">):</span>
    <span class="s">"""Average of outcomes beyond the `level` quantile (Tail Value at Risk)."""</span>
    <span class="n">threshold</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">quantile</span><span class="p">(</span><span class="n">annual_values</span><span class="p">,</span> <span class="n">level</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">annual_values</span><span class="p">[</span><span class="n">annual_values</span> <span class="o">&gt;=</span> <span class="n">threshold</span><span class="p">].</span><span class="n">mean</span><span class="p">()</span>

<span class="k">for</span> <span class="n">rp</span> <span class="ow">in</span> <span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">50</span><span class="p">,</span> <span class="mi">100</span><span class="p">,</span> <span class="mi">200</span><span class="p">):</span>
    <span class="k">print</span><span class="p">(</span>
        <span class="sa">f</span><span class="s">"1-in-</span><span class="si">{</span><span class="n">rp</span><span class="si">:</span><span class="o">&lt;</span><span class="mi">3</span><span class="si">}</span><span class="s">  AEP gross </span><span class="si">{</span><span class="n">return_period_loss</span><span class="p">(</span><span class="n">gross</span><span class="p">,</span> <span class="n">rp</span><span class="p">)</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="mf">6.1</span><span class="n">f</span><span class="si">}</span><span class="s">m"</span>
        <span class="sa">f</span><span class="s">"   OEP gross </span><span class="si">{</span><span class="n">return_period_loss</span><span class="p">(</span><span class="n">max_loss</span><span class="p">,</span> <span class="n">rp</span><span class="p">)</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="mf">6.1</span><span class="n">f</span><span class="si">}</span><span class="s">m"</span>
        <span class="sa">f</span><span class="s">"   AEP net </span><span class="si">{</span><span class="n">return_period_loss</span><span class="p">(</span><span class="n">net</span><span class="p">,</span> <span class="n">rp</span><span class="p">)</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="mf">6.1</span><span class="n">f</span><span class="si">}</span><span class="s">m"</span>
    <span class="p">)</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Average annual loss (gross): </span><span class="si">{</span><span class="n">gross</span><span class="p">.</span><span class="n">mean</span><span class="p">()</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">m"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"99.5% TVaR (net):            </span><span class="si">{</span><span class="n">tvar</span><span class="p">(</span><span class="n">net</span><span class="p">,</span> <span class="mf">0.995</span><span class="p">)</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">m"</span><span class="p">)</span>
</code></pre></div></div>

<p>The 1 in 200 AEP is the 99.5th percentile of annual loss, which is the confidence level used in Solvency II style capital calculations. TVaR averages the whole tail beyond that point and is a coherent risk measure in the sense of Artzner et al. (1999), which VaR is not.</p>

<h2 id="how-many-simulations-is-enough">How many simulations is enough?</h2>

<p>Simulation error is often ignored, but it is easy to estimate. For a mean, the standard error falls with the square root of the number of simulations:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">se_aal</span> <span class="o">=</span> <span class="n">gross</span><span class="p">.</span><span class="n">std</span><span class="p">(</span><span class="n">ddof</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="n">np</span><span class="p">.</span><span class="n">sqrt</span><span class="p">(</span><span class="n">n_years</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"AAL = </span><span class="si">{</span><span class="n">gross</span><span class="p">.</span><span class="n">mean</span><span class="p">()</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="p">.</span><span class="mi">3</span><span class="n">f</span><span class="si">}</span><span class="s">m +/- </span><span class="si">{</span><span class="mf">1.96</span><span class="o">*</span><span class="n">se_aal</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="p">.</span><span class="mi">3</span><span class="n">f</span><span class="si">}</span><span class="s">m (95% interval)"</span><span class="p">)</span>
</code></pre></div></div>

<p>Tail quantiles converge much more slowly than the mean because only a handful of simulations land beyond them. With 100,000 years, only 500 sit beyond the 1 in 200 point. A quick, honest check is to rerun with several seeds and look at the spread of the quantile you care about. If a 1 in 200 figure moves by more than your materiality threshold between seeds, you need more simulations or a variance reduction technique; Glasserman (2003) covers importance sampling and control variates in detail.</p>

<h2 id="common-pitfalls">Common pitfalls</h2>

<ul>
  <li><strong>Not seeding the generator.</strong> Results that change on every run cannot be reviewed or reconciled. Use <code class="language-plaintext highlighter-rouge">np.random.default_rng(seed)</code> and record the seed.</li>
  <li><strong>Using the legacy NumPy API.</strong> <code class="language-plaintext highlighter-rouge">np.random.seed</code> and global state make parallel runs hard to reproduce. The <code class="language-plaintext highlighter-rouge">Generator</code> API is the current recommendation in the NumPy documentation.</li>
  <li><strong>Applying per-risk terms to aggregates.</strong> As above, this silently changes the contract.</li>
  <li><strong>Forgetting dependence.</strong> The collective model assumes independence. Catastrophe events, inflation and the underwriting cycle all create dependence between losses and between lines; the next post on copulas looks at one way to handle that.</li>
  <li><strong>Reporting false precision.</strong> If your parameters come from ten years of data, the parameter uncertainty dwarfs the simulation error. Say so.</li>
</ul>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>This simple model already supports useful work: comparing reinsurance structures, sizing retentions and explaining tail risk to underwriters. Extensions worth adding, roughly in order of value, are parameter uncertainty (simulate the parameters as well as the losses), inflation and exposure trends, reinstatement terms, and dependence between lines.</p>

<h2 id="references">References</h2>

<ul>
  <li>Klugman, S. A., Panjer, H. H. and Willmot, G. E. (2019). <em>Loss Models: From Data to Decisions</em>, 5th edition. Wiley.</li>
  <li>Glasserman, P. (2003). <em>Monte Carlo Methods in Financial Engineering</em>. Springer.</li>
  <li>Artzner, P., Delbaen, F., Eber, J.-M. and Heath, D. (1999). Coherent measures of risk. <em>Mathematical Finance</em>, 9(3), 203 to 228.</li>
  <li>Kaas, R., Goovaerts, M., Dhaene, J. and Denuit, M. (2008). <em>Modern Actuarial Risk Theory: Using R</em>, 2nd edition. Springer.</li>
  <li>NumPy documentation. <em>Random Generator</em>. numpy.org/doc/stable/reference/random/generator.html</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Actuarial" /><category term="Risk Modelling" /><category term="Monte Carlo" /><category term="Collective Risk Model" /><category term="Reinsurance" /><category term="Python" /><category term="NumPy" /><summary type="html"><![CDATA[Build a frequency and severity simulation in NumPy, apply an excess of loss layer, read OEP and AEP curves correctly, and know how many simulations you actually need.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Emerging Risks in the London Specialty Market: What the Data Can and Can’t Tell Us</title><link href="https://gsehurut.github.io/blog/2024/03/15/london-market-trends/" rel="alternate" type="text/html" title="Emerging Risks in the London Specialty Market: What the Data Can and Can’t Tell Us" /><published>2024-03-15T00:00:00+00:00</published><updated>2024-03-15T00:00:00+00:00</updated><id>https://gsehurut.github.io/blog/2024/03/15/london-market-trends</id><content type="html" xml:base="https://gsehurut.github.io/blog/2024/03/15/london-market-trends/"><![CDATA[<p>The London market exists to insure risks that are large, unusual or hard to price anywhere else. That makes it the place where emerging risks show up first, and it also makes it the place where historical data is least reliable as a guide to the future. This post looks at four themes that matter for specialty portfolios and, more importantly, at what each one means for the people building the data and models behind underwriting decisions.</p>

<h2 id="why-emerging-is-a-data-problem">Why “emerging” is a data problem</h2>

<p>Most actuarial techniques assume the past is a reasonable guide to the future once you adjust for inflation, exposure and mix. Emerging risks break that assumption in three ways:</p>

<ol>
  <li><strong>Short or no history.</strong> Cyber has only a couple of decades of meaningful loss data, and the threat changes faster than the data accumulates.</li>
  <li><strong>Changing contract wordings.</strong> When the market rewrites exclusions, past losses stop being comparable with future exposure, even if the underlying hazard is unchanged.</li>
  <li><strong>Accumulation over independence.</strong> Many of these risks are dangerous because one event hits many policies at once. Models built on independent claims understate the tail.</li>
</ol>

<p>The practical consequence is that exposure data, scenario analysis and clear documentation of assumptions become more valuable than another decimal place of precision on a fitted distribution.</p>

<h2 id="1-cyber-and-systemic-accumulation">1. Cyber and systemic accumulation</h2>

<p>Cyber is the textbook accumulation risk: a single vulnerability in widely used software or a cloud outage can trigger losses across thousands of insureds in different territories and classes. The market response has been as much about wording as pricing. Lloyd’s Market Bulletin Y5381 (August 2022) required managing agents to include clauses excluding losses from state-backed cyber attacks in standalone cyber policies from March 2023, and the Lloyd’s Market Association published model clauses (LMA5564 to LMA5567) to support that.</p>

<p>For an analyst, this has concrete implications:</p>

<ul>
  <li><strong>Capture the wording version at policy level.</strong> If you cannot tell which exclusion a policy carries, you cannot compare its exposure with last year’s book.</li>
  <li><strong>Model scenarios, not just distributions.</strong> Realistic Disaster Scenarios for cyber (for example a major cloud provider outage) are a better communication tool for underwriters and boards than a fitted severity curve.</li>
  <li><strong>Track non-affirmative (“silent”) cyber</strong> in property and casualty lines, where cyber exposure exists without being explicitly priced.</li>
</ul>

<h2 id="2-war-political-violence-and-political-risk">2. War, political violence and political risk</h2>

<p>The aircraft lessor claims arising from aircraft stranded in Russia after 2022 were a reminder that political risk and aviation war exposures can crystallise suddenly and at very large scale, and that the question of <em>which</em> policy responds (all risks or war risks, contingent or possessed) can matter as much as the size of the loss.</p>

<p>What this means for data:</p>

<ul>
  <li><strong>Exposure by country and counterparty must be current.</strong> Quarterly snapshots are often too slow when a sanctions regime changes in a week.</li>
  <li><strong>Aggregation needs a clear hierarchy.</strong> A single obligor or government can sit behind many policies through different brokers and coverholders. Without consistent entity resolution, the same exposure is counted under several names or missed entirely.</li>
  <li><strong>Scenario definitions should be versioned</strong> so that a figure reported to a risk committee can be reproduced months later.</li>
</ul>

<h2 id="3-natural-catastrophe-the-rise-of-secondary-perils">3. Natural catastrophe: the rise of secondary perils</h2>

<p>Peak perils like US hurricane and Japanese earthquake dominate catastrophe model output, but much of the insured loss in recent years has come from so-called secondary perils: severe convective storms, flooding and wildfire. The Swiss Re Institute’s annual <em>sigma</em> reports on natural catastrophes have repeatedly highlighted this trend, alongside the effect of rising exposure values in hazard-prone areas.</p>

<p>For a specialty book this matters in a few ways:</p>

<ul>
  <li>Secondary perils are often less well represented in vendor catastrophe models, so <strong>model adjustments and loadings need to be explicit and documented</strong>.</li>
  <li><strong>Geocoding quality drives everything.</strong> A risk located to postcode centroid rather than building coordinates can land on the wrong side of a flood zone boundary.</li>
  <li><strong>Exposure growth</strong> (more insured value in the same place) can look like a trend in frequency if you only analyse loss counts.</li>
</ul>

<h2 id="4-supply-chains-and-contingent-exposures">4. Supply chains and contingent exposures</h2>

<p>Marine cargo, contingent business interruption and trade credit share a common feature: the loss often occurs far from the insured. A port closure, a canal blockage or a regional conflict can generate claims across cargo, hull, war and political risk policies simultaneously. Capturing <em>where</em> goods are and <em>which routes</em> they take is hard, and most portfolios rely on declared values, voyage-level bordereaux or storage location schedules of mixed quality.</p>

<h2 id="what-good-looks-like-for-analytics-teams">What good looks like for analytics teams</h2>

<p>Across all four themes, the same habits make a portfolio easier to understand:</p>

<table>
  <thead>
    <tr>
      <th>Habit</th>
      <th>Why it matters</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Policy-level wording and exclusion flags</td>
      <td>Makes year-on-year exposure comparisons valid</td>
    </tr>
    <tr>
      <td>Clean entity and geography reference data</td>
      <td>Prevents double counting and missed aggregations</td>
    </tr>
    <tr>
      <td>Versioned scenario definitions</td>
      <td>Makes committee figures reproducible</td>
    </tr>
    <tr>
      <td>Separate “exposure growth” from “hazard change”</td>
      <td>Avoids reading inflation and growth as trend</td>
    </tr>
    <tr>
      <td>Documented model adjustments</td>
      <td>Keeps expert judgement visible and reviewable</td>
    </tr>
  </tbody>
</table>

<p>None of these are glamorous, and none require machine learning. They do require good data engineering and a close working relationship between analysts, exposure managers and underwriters.</p>

<h2 id="closing-thought">Closing thought</h2>

<p>The temptation with emerging risks is to reach for a more sophisticated model. In my experience, the bigger gains come from knowing exactly what you have written, where it is, and which wording applies. Sophisticated models built on uncertain exposure data produce confident looking numbers that are not worth very much.</p>

<h2 id="references">References</h2>

<ul>
  <li>Lloyd’s (2022). <em>Market Bulletin Y5381: State backed cyber-attack exclusions</em>. Lloyd’s of London.</li>
  <li>Lloyd’s Market Association (2021). <em>Cyber war and cyber operation exclusion clauses, LMA5564 to LMA5567</em>.</li>
  <li>Swiss Re Institute. <em>sigma: Natural catastrophes</em> (annual series). Swiss Re.</li>
  <li>Lloyd’s. <em>Realistic Disaster Scenarios: Scenario specification</em> (published annually). Lloyd’s of London.</li>
  <li>Kaas, R., Goovaerts, M., Dhaene, J. and Denuit, M. (2008). <em>Modern Actuarial Risk Theory: Using R</em>, 2nd edition. Springer.</li>
</ul>]]></content><author><name>Goabaone Sehurutshe</name><email>goabaonesehurutshe@gmail.com</email></author><category term="Insurance" /><category term="Risk" /><category term="London Market" /><category term="Specialty Insurance" /><category term="Emerging Risk" /><category term="Cyber" /><category term="Exposure Management" /><summary type="html"><![CDATA[Cyber, war and political violence, secondary natural perils and accumulation: how emerging risks show up in London market data, and why analysts should be humble about history.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" /><media:content medium="image" url="https://gsehurut.github.io/pictures/goabaone-headshot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>