Grouping LDTI Cohorts by Issue Year in Python

Grouping LDTI cohorts by issue year in Python means turning a seriatim in-force file into a set of annual issue-year cohorts, each with a cohort_id that stays identical across every valuation so the liability history never breaks. This focused guide gives a runnable assignment routine, explains it block by block, and hardens it against the boundary and reinsurance edge cases that quietly corrupt cohorts. It sits under LDTI Cohort & Transition Management within the IFRS 17 & LDTI Filing Automation domain.

The Problem

Under ASU 2018-12, the annual issue-year cohort is the unit of account for the liability for future policy benefits, and the single most damaging bug in the whole pipeline is a policy that lands in the wrong cohort or that moves between cohorts as data is refreshed. The assignment looks trivial — take the issue year, add the product and segment, make a key — but it has to be deterministic to the byte across quarters, robust to malformed dates, and stable even when the seriatim extract is re-pulled from a source system that has since edited a field. This page builds an assignment function that produces a stable cohort_id, then addresses the mid-year boundary, reinsurance-assumed business, and remapping stability that turn a one-line groupby into a production concern.

A Minimal Working Example

The routine below assigns each seriatim policy to an annual cohort keyed on issue year, product, and segment, and returns both the labeled frame and a compact per-cohort summary. It has no framework dependencies beyond pandas and runs as-is:

from __future__ import annotations

import pandas as pd

# Segment dimensions used inside an issue year, in a fixed, recorded order.
SEGMENT_KEYS: tuple[str, ...] = ("product_code", "issue_age_band", "channel")


def issue_age_band(issue_age: int) -> str:
    """Bucket issue age into stable bands so small cohorts don't fragment."""
    if issue_age < 40:
        return "u40"
    if issue_age < 55:
        return "40_54"
    if issue_age < 70:
        return "55_69"
    return "70p"


def assign_cohort_id(row: pd.Series) -> str:
    """Deterministic annual issue-year cohort key. Issue year is absolute."""
    issue_date = pd.to_datetime(row["issue_date"]).date()
    parts = [f"{issue_date.year:04d}"]
    for key in SEGMENT_KEYS:
        parts.append(str(row[key]).strip().upper())
    return "-".join(parts)


def group_into_cohorts(seriatim: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
    """Label every policy with a cohort_id and summarise each cohort."""
    if seriatim["issue_date"].isna().any():
        raise ValueError("issue_date must be non-null before cohort assignment")

    labeled = seriatim.copy()
    labeled["issue_age_band"] = labeled["issue_age"].map(issue_age_band)
    labeled["cohort_id"] = labeled.apply(assign_cohort_id, axis=1)

    summary = (
        labeled.groupby("cohort_id")
        .agg(
            policies=("policy_id", "count"),
            issue_year=("issue_date", lambda s: pd.to_datetime(s).dt.year.min()),
            face_amount=("face_amount", "sum"),
        )
        .reset_index()
        .sort_values("cohort_id")
        .reset_index(drop=True)
    )
    return labeled, summary


if __name__ == "__main__":
    inforce = pd.DataFrame(
        {
            "policy_id": ["P100", "P101", "P102", "P103"],
            "issue_date": ["2022-03-01", "2022-12-31", "2023-01-01", "2022-07-15"],
            "product_code": ["term20", "term20", "term20", "whole"],
            "issue_age": [34, 61, 45, 52],
            "channel": ["retail", "retail", "broker", "retail"],
            "face_amount": [250_000, 100_000, 500_000, 300_000],
        }
    )
    labeled, summary = group_into_cohorts(inforce)
    print(summary.to_string(index=False))
Seriatim frame to stable cohort_id assignment pipeline A seriatim in-force frame passes a validation gate that rejects null issue dates, then an assignment step parses the issue year and normalizes product, age band, and channel into a stable cohort_id, yielding grouped annual issue-year cohorts and a per-cohort summary. Seriatim frame policy_id · issue_date Validate dates reject null issue_date Assign cohort_id year + product + segment normalize + uppercase Annual issue-year cohorts + per-cohort summary

How It Works, Block by Block

SEGMENT_KEYS fixes the grouping in one place. The tuple names the dimensions used inside an issue year and, crucially, their order. Because cohort_id is built by joining these in sequence, changing the tuple changes every key — so it is declared once, recorded as configuration, and never reordered casually. Keeping it explicit also documents the segmentation policy for the next actuary who opens the file.

issue_age_band bins age before it enters the key. Raw issue age would fragment the block into hundreds of tiny cohorts, each with too little experience to unlock cleanly. Bucketing into a few stable bands keeps cohorts populous enough to be credible while still separating materially different mortality profiles. The band edges are part of the recorded policy: move them and you reshuffle the population.

assign_cohort_id parses the year and normalizes the rest. The issue year is extracted from a parsed date, not sliced off a string, so "2022-03-01" and "03/01/2022" resolve to the same year. Every segment component is stripped and upper-cased so "term20", "Term20", and " TERM20 " collapse to one key rather than three phantom cohorts. The year is formatted to four digits, making the key sort naturally and read unambiguously.

group_into_cohorts validates first, then labels and summarizes. It refuses to proceed with a null issue_date, because a missing year is precisely the record that would silently fall into a garbage cohort. It then labels every policy and produces a per-cohort summary — count, issue year, and total face amount — that feeds the reconciliation and disclosure roll-forwards. The result is sorted by cohort_id so successive runs emit rows in the same order, which matters when the output is hashed for the audit trail.

Edge Cases and Production Hardening

Mid-year issue boundaries. The single riskiest input is a contract issued on 31 December or 1 January, because a parsing or time-zone slip can shift it a full year and therefore a full cohort. A policy issued 2022-12-31 belongs to the 2022 cohort, full stop — but a naive pd.to_datetime on a UTC-stored timestamp read in a negative-offset zone can render it as 2022-12-30 or even roll it. Always assign on the date, never a localized datetime, and pin the boundary in a test:

import pandas as pd


def test_year_end_boundary_is_stable():
    """A 31 December issue must never leak into the next year's cohort."""
    row_dec = pd.Series(
        {"issue_date": "2022-12-31", "product_code": "term20",
         "issue_age_band": "40_54", "channel": "retail"}
    )
    row_jan = pd.Series(
        {"issue_date": "2023-01-01", "product_code": "term20",
         "issue_age_band": "40_54", "channel": "retail"}
    )
    assert assign_cohort_id(row_dec).startswith("2022-")
    assert assign_cohort_id(row_jan).startswith("2023-")

Reinsurance-assumed business. Business assumed under a reinsurance treaty carries the underlying contract’s original issue date, not the treaty effective date, and it must be cohorted by that original issue year to stay comparable with directly written contracts of the same vintage. If the assuming ceding statement supplies only a treaty date, the assignment silently mis-vintages the whole block. Guard it by carrying an explicit original_issue_date and an assumed flag, and assign on the original date:

def cohort_date_for(row: pd.Series) -> pd.Timestamp:
    """Assumed reinsurance is cohorted on the underlying original issue year."""
    if bool(row.get("assumed", False)):
        original = row.get("original_issue_date")
        if pd.isna(original):
            raise ValueError("assumed business requires original_issue_date")
        return pd.to_datetime(original)
    return pd.to_datetime(row["issue_date"])

Cohort remapping stability across valuations. A cohort_id must be identical at every quarter for the life of the block; if a refreshed extract changes a policy’s product code or channel, re-deriving the key silently migrates the policy and severs its remeasurement history. The defensible pattern is to freeze the assignment at inception — persist the cohort_id on first appearance and reuse it thereafter, treating any subsequent attribute change as a documented, audited exception rather than an automatic re-map. A cheap guard compares a freshly derived key against the stored one and flags the drift instead of applying it.

Compliance Note

Stable issue-year grouping is the mechanical foundation ASU 2018-12 rests on: because the cohort is the unit at which the liability for future policy benefits is measured and remeasured, an unstable cohort_id invalidates every downstream net premium ratio and disclosure roll-forward, and cannot survive the reproducibility expectation an auditor brings to the amended ASC 944. Validating the seriatim inputs at the boundary — non-null dates, recognized product codes, present original issue dates for assumed business — is a direct application of the input-contract discipline in Validating Actuarial Input Schemas with Pydantic, and it aligns with ASOP No. 56 (Modeling), which asks that a model’s data, controls, and reconciliation be documented rather than assumed. The same cohorts feed the deferred-profit machinery in Contractual Service Margin Automation for carriers that also report under IFRS 17.

Frequently Asked Questions

What goes into an LDTI cohort_id?

At minimum the issue year, which is absolute, plus the product type and any segment dimensions the insurer treats as materially similar, such as an issue-age band and distribution channel. The components are normalized and joined in a fixed order so the key is deterministic and stable across every valuation.

How do you handle a policy issued on December 31?

Assign it strictly by its calendar issue date, so a 31 December issue belongs to that year’s cohort and never the next. Parse the issue date as a date rather than a localized timestamp to avoid a time-zone slip pushing it across the year boundary, and cover the boundary with an explicit test.

How is reinsurance-assumed business cohorted?

Assumed business is grouped by the underlying contract’s original issue year, not the treaty effective date, so it stays comparable with directly written contracts of the same vintage. Carry an explicit original issue date and assign on it, raising an error if it is missing rather than defaulting to the treaty date.

Why must a cohort_id stay identical across valuations?

Because the cohort carries the net premium ratio and remeasurement history, a policy that migrates to a different cohort between quarters breaks the roll-forward and the reproducibility of prior-period figures. Freeze the assignment at inception and treat any later attribute change as a documented exception, not an automatic re-map.

Up a level: LDTI Cohort & Transition Management · IFRS 17 & LDTI Filing Automation


Regulatory references: FASB ASU 2018-12, Targeted Improvements to the Accounting for Long-Duration Contracts (ASC 944); Actuarial Standards Board ASOP No. 56 (Modeling).