Methodology

Overhead view of a desk. An index card reads "How much of 2021 is still unpaid?". Beside it a loss development triangle is worked out by hand on graph paper, its column headings crossed out and rewritten as cumulative and development, with a mechanical pencil and steel ruler across it and a small printed chart pinned to the right.

How this platform got its shape

Insurance claims are not paid all at once. One opened in 2021 is still being paid out in 2024, so if you want to know how much of that year is still owed, there is nowhere to look it up. Somebody has to build the answer. That is the question written on the card above, and building answers like it is what everything here exists to do.

Six pieces sit underneath. One keeps the claims and reshapes them into tables people can actually ask questions of. One runs that reshaping on a schedule, unattended. One takes claims in as they happen instead of waiting for the nightly run. One writes down every server and database as a file, so the whole thing can be rebuilt from that file rather than from memory. One handles a continuous stream of incoming events, including the ones that turn up late. And one estimates what a policy ought to cost, given who is buying it.

I trained as an actuary, so I knew what these numbers were supposed to mean before I had to decide where they would live. That is most of what this page is about. And because a number nobody can reproduce is a number nobody can defend, a good part of what follows is simply proof of where each one came from. Every claim here points at a file you can open, and wherever I chose one tool over another I have tried to say what would make me choose differently.

How the Data Travels

CSV / Parquet Source Data BigQuery Raw Tables Dataform stg / int / marts Quality Assertions FastAPI This Dashboard Cloud Run Public URL GitHub Actions CI/CD: lint, test, build, deploy on every push to main

The Six Projects

P01
Claims Data Warehouse

Synthetic insurance data (policyholders, policies, claims, payments, coverages) loaded into BigQuery. Dataform manages 5 staging tables, 3 intermediate transforms, 6 dimension/fact tables, and 2 report views. 16 data quality assertions run on every deploy.

P02
Orchestrated ELT Pipeline

A Cloud Run container runs the full extract-load-transform cycle on a schedule. Cloud Run + Cloud Scheduler was chosen over Cloud Composer: same reliability at a fraction of the cost for a single-DAG workload.

P03
Streaming Intake

Pub/Sub topic receives claim events in real time. A Cloud Run subscriber validates, deduplicates, and writes to BigQuery. Demonstrates event-driven architecture alongside the batch ELT pattern.

P04
Infrastructure as Code

Every GCP resource behind P01 to P03 is defined in Terraform: reusable modules for BigQuery, GCS, Pub/Sub, Cloud Run and IAM, with dev and prod separated by variable-driven naming. CI checks formatting on every pull request and authenticates to GCP through Workload Identity Federation, so there are no service account keys to leak anywhere in the pipeline. Plans and applies are still run by hand — listed as an open gap under What This Deliberately Does Not Do rather than pretended otherwise.

P05
Streaming Claims Pipeline

A true streaming Apache Beam pipeline on Dataflow, where P03 runs Beam in batch. It windows on event time rather than arrival time, fires early results every 30 seconds and again when late events land, still accepts data up to an hour behind the watermark, and routes malformed events to a dead-letter topic with the reason attached.

P06
Pricing ML Model

A GLM (Generalized Linear Model) scores 144K policies with a predicted pure premium. Output lands in dev_pricing_ml.model_scoring and feeds the Pricing Adequacy page of this dashboard.

Where Four Jobs Meet

A claims table is where four jobs meet. In most insurers those jobs sit in different teams, and the handoffs between them are where the time and the disagreements go. Building all four sides is the point of this platform.

Actuarial Science What the number has to mean Analytics What is actually being asked Data Engineering How it is computed, the same way every time Data Governance Why you can still trust it in a year One modelled table fct_claims · fct_claim_payments · rpt_loss_triangle A decision somebody can defend reserve · rate · reinsurance · capital

How a Question Becomes a Table

This is the actual path taken by the Loss Triangle page, step by step. It is the shape every other page in this dashboard follows.

  1. 1
    Start from the actuarial question, not the data

    “How much of the 2021 accident year is still unpaid?” That is a reserving question, and answering it needs cumulative paid amounts arranged by accident year against development year. Nothing about the source files suggests that shape; it comes from the question.

  2. 2
    Decide the grain — this is the decision that matters

    A claim is not paid once. It pays out repeatedly over years, which is the entire reason triangles exist. So the base fact is fct_claim_payments, one row per payment, and development_year is derived at that grain from accident date to payment date. Choosing claim grain here would not produce a slow query. It would produce a wrong reserve, quietly.

  3. 3
    Pin the source contract before transforming

    stg_claim_payments declares payment_id unique and four columns non-null. A single duplicated payment does not fail anything downstream — it inflates one cell of the triangle and every development factor derived from it. The assertion is cheaper than the investigation.

  4. 4
    Transform in layers, one job each

    int_claim_payments_cumulative accumulates with a window function partitioned by accident year and ordered by development year. rpt_loss_triangle only pivots that into columns. Splitting them means the accumulation logic exists once, and the pivot can change without anyone re-deriving the maths.

  5. 5
    Assert the thing that would be invisible if wrong

    assert_payment_dates requires that no payment precedes its accident date. A negative development year is not a curiosity: it shifts money into a cell that should not exist, and the triangle still renders perfectly. The assertions worth writing are the ones whose failure looks like a valid answer.

  6. 6
    Serve a table, not a query

    The dashboard reads rpt_loss_triangle with a SELECT * and computes chain-ladder factors in the page. No business logic lives in the presentation layer, so the number on screen and the number in a notebook come from the same place.

The Layering Rule

Sixteen tables that Dataform builds, across five layers. The rule is that each layer is allowed to do one kind of work, so when a page needs a new column the question is which layer does this belong to rather than which query do I edit.

raw_ declared, never created — the load stays a separate, auditable step 5 declarations stg_ rename, cast, trim — strictly one to one with the source 5 tables int_ joins and derivations more than one mart needs — not exposed 3 tables dim_ / fct_ the model — partitioned, clustered, and asserted on its keys 6 tables rpt_ shaped for one question and cheap to read 2 tables Each layer does one kind of work. A new column belongs to exactly one of them.
raw_ · 5 declarations

Declared, never created. Dataform reads these tables and is not allowed to write them, which keeps loading the data a separate and auditable step from transforming it. It is also the single most common way to break a first rebuild — run the workflow against an empty raw layer and it fails at the first staging model.

stg_ · 5 tables

Rename, cast, trim. Strictly one-to-one with the source, no joins and no business logic, so a change in the source has exactly one place to land. This is where the uniqueness and non-null contracts are declared.

int_ · 3 tables

Joins and derivations that more than one mart needs: enriched claims, cumulative payments, policy exposure. Not exposed to any consumer, which is what makes them safe to restructure.

dim_ / fct_ · 6 tables

The model itself, and the only layer anyone should join against. fct_claims is partitioned by accident_date and clustered on coverage_type, claim_status — the two cuts every actuarial question starts from.

rpt_ · 2 tables

Shaped for one question and cheap to read, because a dashboard page that runs a six-join query is a dashboard page that eventually times out in front of somebody.

Actuarial Decisions That Are Really Modelling Decisions

These four look like schema details in a code review. Each of them is an actuarial judgement that became permanent the moment it was written into a table.

Partition on accident date, not report date

Both dates are in the table; only one is the partition key. Every reserving and pricing cut is by accident year, so partitioning there makes the common query prune to a fraction of the table for free. Report date is the operational view — useful for claims handling, wrong as the physical layout of an analytical fact.

Derive development year once

It is computed at payment grain in the warehouse and never recomputed downstream. The alternative — every analyst deriving it in their own query — is exactly how two people produce two reserves from one database and spend a week finding out why.

Keep indemnity and expense apart

fct_claims carries total_indemnity and total_expense as separate sums. Allocated expense is not loss. Rolling them into one column overstates severity, distorts the development pattern, and cannot be undone later without going back to payment grain.

Exposure is an offset, not a feature

The frequency model is a Poisson GLM with a log link and exposure entering as an offset, so a policy in force three months is not compared directly against one in force twelve. Passing exposure as an ordinary covariate lets the model estimate a coefficient for something the arithmetic already fixes at one.

What Governance Means Here

If somebody asks how a figure on this dashboard was produced — an auditor, a regulator, a colleague, or you in eight months — you have to be able to show them, step by step, and get the same number again. Everything below exists for that, and each part is defending against something specific.

The data arrives loaded from files by a script A bad file cannot quietly become a number It is checked before anything is built on it A duplicated payment cannot inflate a reserve It is built in steps each step names its inputs Any figure can be traced back to where it came from It is published tested before anyone sees it A broken page cannot reach a reader Every one of these steps is a file kept under version control so a change to how a reserve is calculated is a dated, authored, reviewed commit
Can you show where a number came from?
  • Each step names its own inputs — so the path from a figure on this screen back to the file it came from is something the tool draws for you, not something you reconstruct by reading SQL. In practice that is what ref() buys.
  • The whole thing rebuilds from the original files — and the procedure is written down. That is not theoretical: on 14 August 2026 every dataset was empty, and it was rebuilt from the raw files in one afternoon.
  • Every calculation is a file under version control — so a change to how a reserve is worked out carries a date, an author and a reason. The live branch only accepts changes through a reviewed pull request with passing checks.
Can you trust what went in?
  • Checks run as part of building, not afterwards — so a broken assumption stops the tables that depend on it instead of turning up later in a report. Sixteen of them, on uniqueness, missing values, referential integrity, and dates that cannot be right.
  • This is an actuarial obligation, not a preference — ASOP No. 23, Data Quality, requires the actuary to review data for appropriateness and to disclose its limitations rather than assume it is fit for the purpose. These checks are that review, written down once and re-run on every build.
Who is allowed to change what?
  • One account, six named permissions — bigquery.dataEditor, bigquery.jobUser, storage.objectAdmin, pubsub.editor, run.invoker, and iam.serviceAccountTokenCreator. No Owner, no Editor, nothing granted because it was faster.
  • There are no passwords or keys to leak — the automation proves who it is through a trust relationship tied to this specific repository, so there is no credential file that can be copied, committed, or forgotten.
  • Development and production are separate by construction — dev_claims_* against claims_*, resolved from one variable in each model. Promoting is a variable change, not a second copy of the logic.
Would you know if it broke?
  • New versions are tested before they take traffic — each one goes out with no visitors routed to it, gets checked on its own private address for real content rather than a 200, and only then takes over. If the check fails, the previous version never stopped serving.
  • A daily check opens an issue, and closes it — it confirms the site answers, the warehouse is connected, and the old links still redirect. An open issue therefore means broken now, not broken once.
  • A runaway query fails instead of billing — max_bytes_billed caps any single query at 10 GiB, and the tables are partitioned and clustered so the ordinary questions read a fraction of the data.
What governance here is still missing

Saying so is part of the practice: the same standard that requires reviewing the data requires disclosing what it cannot support.

  • Last December’s reserve cannot be reproduced exactly as it stood — policy records are overwritten when they change, so the history they had at the time is gone. For the questions on this dashboard that is fine. For an audit it would not be.
  • The pricing model has no formal governance — no challenger model, no monitoring for drift as the book changes, no documented review. The fitted coefficients are inspectable, which is the floor, not the standard.
  • The infrastructure description is not enforced automatically — a person notices if the written definition and the live project drift apart.
  • There is no catalogue or glossary — what a column means lives in the model that builds it and on this page, which works for one author and stops working for four.

Each is stated in full under What This Deliberately Does Not Do.

Google Cloud Choices, and the Case for the Other One

A decision is only worth recording with the conditions that would reverse it. These are the four that shaped the platform.

Dataform over dbt

Native to BigQuery, so there is no runner to host, no warehouse credentials to manage, and the dependency graph and assertions come with the product at no cost.

The other one wins when: the warehouse is not BigQuery, or the team already has dbt packages, macros and a mature test library that would have to be rewritten.

Cloud Run + Cloud Scheduler over Cloud Composer

One scheduled pipeline does not need Airflow. Composer bills a floor of roughly $300 a month for an idle environment; a container that scales to zero costs approximately nothing and fails just as loudly.

The other one wins when: there are many interdependent DAGs, real backfills, per-task retries and a dependency graph a human needs to look at. That is the point where writing your own orchestrator starts costing more than renting one.

Pub/Sub + Cloud Run subscriber alongside Dataflow + Apache Beam

Both exist here on purpose. Per-message validation and insertion is stateless, so a subscriber that scales to zero is the right tool. Anything that aggregates across a stream is not, which is why P05 is a genuine Beam streaming job.

The other one wins when: the answer depends on event time rather than arrival time — windowing, watermarks, late-arriving claims, early results that get corrected. A subscriber cannot express that without rebuilding Beam badly.

statsmodels GLM over BigQuery ML

Pure premium is modelled as a Tweedie GLM with variance power 1.5, which handles a target that is zero for most policies and continuous for the rest. The fitted coefficients matter as much as the predictions, because a rate has to be explainable to a regulator, not just accurate.

The other one wins when: the training set is too large to pull out of BigQuery, or the model family on offer is genuinely adequate and keeping everything in SQL is worth more than control over the fit.

FastAPI + Jinja2 over Streamlit

Full control over the HTML, the CSS and the page weight, and no framework chrome announcing “prototype”. It is server-rendered, so every section is a real URL that search engines and analytics can see.

The other one wins when: the audience is internal and the value is entirely in the analysis. Streamlit gets a working data app in an afternoon; this took considerably longer, and that trade only pays when someone outside the team is looking.

BigQuery + GCS over Postgres

Columnar storage, partition pruning, and storage separated from compute — so the claims table can grow without the query plan changing shape.

The other one wins when: the workload is transactional: many small writes, row-level updates, constraints enforced by the database. A claims system is Postgres. A claims warehouse is not.

What This Deliberately Does Not Do

  • The data is synthetic. Volumes and distributions are realistic enough to exercise the patterns, and no real policyholder data was used. Treat the numbers on these pages as a demonstration of method, not as a book of business.
  • No slowly changing dimensions. dim_policy overwrites on change, so a policy that switches coverage loses its history. Correct for the questions this platform answers today; wrong the moment anyone asks a retrospective one, and a Type 2 rebuild is the honest fix rather than a patch.
  • Terraform is not applied by CI. terraform fmt -check is the only automated gate; plans and applies are run by hand. Plan-on-pull-request is the intended next step and is not wired yet, which means drift between the configuration and the live project is still caught by a person rather than a pipeline.
  • One region, no disaster recovery. Everything runs in us-central1. That is a deliberate cost decision for a portfolio, and it would be an unacceptable one for a production insurer.
  • Reserving stops at the triangle. Chain-ladder development factors are computed and shown. Bornhuetter-Ferguson, a tail factor, and any stochastic reserve with a confidence interval around it are not implemented.

Full decision records, cost estimates and post-mortems live in each project's README in the repository.