Python for Actuarial Students · Course 4
Lesson 5

Interactive Dashboards with Streamlit

From static Excel reports to a live application: the Executive Underwriting Portal, where pricing actuaries move rating factors and see the portfolio respond.

Lesson 5 · OverviewBy the end of this lesson you can…

  • Explain Streamlit’s model: the script reruns on every interaction
  • Separate the numbers (pure, tested functions) from the page that shows them
  • Lay out a page with a sidebar, columns, tabs, metrics, and charts
  • Cache data and models, and keep state across reruns with st.session_state
  • Accept a file upload, validate it, and price it through the API
  • Test an app headlessly with AppTest
75 min
Concepts & worked examples
75 min
Lab: the underwriting portal

Companion notebook: lesson-05-dashboards.ipynb — worked examples and self-checking exercises.

WhyWhat the rate review spreadsheet can’t do

SpreadsheetPortal
A copy of the book, as of last monthThe book in the database, now
Expected losses pasted in from a model runComputed by the champion model the API serves
One scenario per tab, emailed aroundMove a slider; compare saved scenarios
“Which version is right?”One URL, one source of truth

The people who decide rate changes (chief actuary, underwriting, finance) want to ask “what if urban goes up 20% instead of 25%?” and see the answer while they are still in the meeting.

Excel stays excellent for ad-hoc analysis and for exhibits (Course 1, Lesson 7). A portal is for questions that are asked again and again, against live data.

Part 1

The Numbers

Pure functions first; the page is a thin layer.

portfolio.pyReprice the book, no UI involved

@dataclass(frozen=True)
class Adjustments:
    base_change: float = 0.0
    urban_factor: float = 1.0
    non_urban_factor: float = 1.0
    young_factor: float = 1.0      # drivers under 25
    sports_factor: float = 1.0
    cap: float | None = None       # e.g. 0.25
    expense_ratio: float = 0.25

def reprice(book, adj):
    b = book
    factor = ((1 + adj.base_change)
        * np.where(b.urban, adj.urban_factor,
                   adj.non_urban_factor)
        * np.where(b.driver_age < 25, adj.young_factor, 1)
        * np.where(b.vehicle_style == "Sports",
                   adj.sports_factor, 1))
    if adj.cap is not None:
        factor = np.clip(factor, 1 - adj.cap, 1 + adj.cap)
    out = b.assign(proposed_liab=(b.liab_premium * factor).round(2))
    out["change"] = out.proposed_liab / out.liab_premium - 1
    return out
  • summary(): premium, projected loss ratio (expected loss ÷ premium), margin (1 − loss ratio − expenses), and how many customers move more than 15%
  • by_segment(): the same by urban, style, or age band
  • Expected losses: the champion GLM’s pure premium per vehicle, trended 1.5 years at 4%, stored with the book

These are ordinary functions on a DataFrame: unit-tested (no change means no change; the cap holds; margin arithmetic) and reusable in a notebook, a report, or the API.

The book today18,249 vehicles, $19.88m of liability premium

SegmentVehiclesLoss ratio
Non-urban7,34758.2%
Urban10,90290.8%
Sports cars1,238103.1%
Drivers 17–2065599.2%
Book18,24977.7%

Projected loss ratio: expected losses $15.45m ÷ current premium. Target 68%. With 25% expenses the margin is −2.7%.

The same story as the Course 3 rate review (overall indication +14.3%): the manual has no urban factor, so urban drivers are subsidized by everyone else.

The proposal from that review: urban × 1.25 (the indication, capped at +25%), non-urban × 0.857.

Part 2

Streamlit

A Python script that becomes a web page.

The modelThe whole script reruns on every interaction

import streamlit as st

st.title("Executive Underwriting Portal")

with st.sidebar:
    urban = st.slider("Urban factor", 0.70, 1.50,
                      step=0.01, key="urban_factor")
    ...

priced = reprice(book(), Adjustments(urban_factor=urban, ...))
s = summary(priced)

c1, c2, c3, c4 = st.columns(4)
c1.metric("Liability premium",
          f"${s['proposed_premium'] / 1e6:,.2f}m",
          f"{s['premium_change']:+.1%}")
c2.metric("Projected loss ratio", ...)
User moves a slider
→
Script reruns top to bottomthe slider now returns the new value
→
Page redrawn
  • No callbacks to wire up: widgets are function calls that return their current value
  • Rerunning everything is cheap only if the expensive parts are cached

Caching & stateWhat survives a rerun

@st.cache_resource            # one per process: connections, models
def database():
    return db.make_engine(settings.sqlalchemy_url())

@st.cache_data(ttl=600)       # a copy per call: data frames
def book() -> pd.DataFrame:
    return db.read_book(database())

# session_state: this user's memory across reruns
st.session_state.setdefault("scenarios", [])
if st.button("Save this scenario", key="save"):
    st.session_state["scenarios"].append({...})

# a link can choose the starting point: ?preset=...
wanted = st.query_params.get("preset", "Current rates")
ToolHoldsShared by
cache_resourceengine, modelall users
cache_datathe book, 10 minall users (copied)
session_statesaved scenariosone browser tab
query_paramsthe presetanyone with the link

Never mutate a cache_data result in place, and never put one user’s data in cache_resource: every other user would see it.

The portalThe rate review proposal, live

The Executive Underwriting Portal with the rate review proposal: sidebar sliders urban factor 1.25 and non-urban 0.86; metrics liability premium 21.70 million up 9.2 percent, projected loss ratio 71.2 percent down 6.5 points, profit margin 3.8 percent up 6.5 points, 10,902 vehicles up more than 15 percent; bar chart of loss ratio for non-urban and urban, current and proposed; histogram of change per vehicle at minus 14 and plus 25 percent

Reading itWhat the proposal does to the book

CurrentProposal
Liability premium$19.88m$21.70m (+9.2%)
Projected loss ratio77.7%71.2%
Margin (25% expenses)−2.7%+3.8%
Urban loss ratio90.8%72.7%
Non-urban loss ratio58.2%68.0%
Sports loss ratio103.1%95.1%
Vehicles up more than 15%010,902
  • Every urban customer’s liability premium rises 25%: that is the dislocation the business must decide to accept
  • Non-urban lands exactly on target; urban is still above it because of the cap
  • Sports cars stay far above target. Adding Sports × 1.10 and under-25 × 1.10 takes the book to 70.6% and a 4.4% margin

The portal doesn’t make the decision. It makes the trade-off between adequacy and customer disruption visible while the people who own the decision are looking at it.

Batch pricingUpload a file, validate it, price it through the API

upload = st.file_uploader("Quote requests (CSV)", type="csv")
if upload is not None:
    raw = pd.read_csv(upload, dtype=str, keep_default_na=False)
    good, bad = [], []
    for row in raw.to_dict(orient="records"):
        try:
            good.append(QuoteRequest.model_validate(...))
        except ValidationError as exc:
            bad.append({"quote_id": ..., "problem": ...})
    st.dataframe(pd.DataFrame(bad))
    if settings.api_url:                  # in production
        quotes = RatingClient(settings.api_url, key) \
                     .quote_batch([r.model_dump(mode="json")
                                   for _, r in good])
    ...
    st.download_button("Download priced quotes",
                       csv_text, "priced_quotes.csv")

The 1,000 new-business requests: 963 priced, 37 listed with the reason, before anything is sent to the API.

  • The portal doesn’t rate anything itself: it calls the same API as every other channel, so a quote from the portal equals a quote from the website
  • Without RP_API_URL (a laptop), it falls back to rating locally with the same engine

TestingAppTest: the app without a browser

from streamlit.testing.v1 import AppTest

def test_moving_a_slider_reprices_the_book(app):
    app.slider(key="urban_factor").set_value(1.25).run()
    assert metric(app, "Liability premium").delta.startswith("+")
    assert metric(app, "Projected loss ratio").delta.startswith("-")

def test_preset_and_saved_scenarios(app):
    app.selectbox(key="preset").set_value(
        "Rate review proposal").run()
    assert app.slider(key="urban_factor").value == 1.25
    app.button(key="save").click().run()
    app.slider(key="base_change").set_value(5).run()
    app.button(key="save").click().run()
    assert len(app.session_state["scenarios"]) == 2

def test_a_link_can_choose_the_preset(...):
    at.query_params["preset"] = "Rate review proposal"
    ...
  • AppTest.from_file(path).run() executes the script as Streamlit would
  • Find widgets by key, set values, click, rerun, then read metrics, tables, and state
  • 4 portal tests run in under two seconds against a temporary SQLite book

Give every widget a key: tests (and presets) can then address it by name.

Actuarial Lab · 75 minThe Executive Underwriting Portal

  1. Score the 2025 book with the champion model: expected annual liability loss
  2. Write and test reprice, summary, by_segment
  3. A sidebar with base change, urban, non-urban, under-25, Sports, cap, expenses
  4. Four metrics, a loss-ratio chart by segment, a histogram of changes
  5. Presets, saved scenarios in session_state, a ?preset= link
  6. A batch tab: upload, validate, price, download
  7. AppTest tests for sliders, presets, and saving

Answer key

Current projected loss ratio77.7%
Proposal: premium change+9.2%
… loss ratio / margin71.2% / 3.8%
… vehicles up > 15%10,902
Worst style after proposalSports, 95.1%

RecapLesson 5 checklist

  • The numbers are pure functions with their own tests
  • The page is a thin layer that calls them
  • Expensive things are cached, correctly scoped
  • Per-user memory lives in session_state
  • Uploads are validated before anything is priced
  • The portal prices through the same API as everyone else
  • The trade-off between adequacy and disruption is on screen
  • AppTest checks the app without a browser
Up next
Lesson 6

Enterprise Data Stores: SQL & SQLAlchemy

Migrating the CSV pipeline to a relational database: keys, constraints, transactions, chunked queries, and the ORM, on SQLite and PostgreSQL.