LDTI Cohort & Transition Management

US GAAP Long-Duration Targeted Improvements (ASU 2018-12) forces every long-duration contract into an annual issue-year cohort whose liability is remeasured each period against updated cash-flow assumptions, and getting that cohorting reproducible in code is the difference between a clean audit and a restatement. This guide, part of the IFRS 17 & LDTI Filing Automation discipline, builds a durable cohort data model in Python, walks the net premium ratio remeasurement, and contrasts the modified- and full-retrospective transition paths. For the seriatim assignment mechanics it leans on Grouping LDTI Cohorts by Issue Year in Python.

LDTI cohort model: seriatim policies to issue-year cohorts to an LFPB remeasurement Seriatim policies feed an assignment step keyed on issue year and product, which fans out to three annual issue-year cohorts (2021, 2022, 2023). Each cohort carries its net premium ratio and remeasurement history, and the three converge into a single liability-for-future-policy-benefits remeasurement posting a catch-up adjustment to income. Seriatim policies issue_date · product Assign by issue year × product stable cohort_id Cohort 2021 NPR + remeasurement history Cohort 2022 NPR + remeasurement history Cohort 2023 NPR + remeasurement history LFPB remeasurement catch-up to income

The Problem: Cohorts Are the Unit of Account

Before ASU 2018-12, the liability for future policy benefits under US GAAP was locked at issue: the assumptions that set the reserve stayed frozen for the life of the block unless a premium deficiency forced a reset. Targeted Improvements ended that. Under the amended ASC 944, insurers must review cash-flow assumptions — mortality, lapse, morbidity, and other policyholder behavior — at least annually, update them when they change, and remeasure the liability retrospectively using a recalculated net premium ratio. Discount-rate assumptions are updated every reporting date on top of that. The reserve is no longer a number you compute once; it is a quantity that lives, and the thing it lives inside is the cohort.

The cohort is the unit of account, and the rule that defines it is narrow: contracts must be grouped into annual cohorts by issue year, and contracts issued in different calendar years may never share a cohort. Within an issue year, contracts are further grouped by product type and by other characteristics an insurer would reasonably treat alike — issue-age band, distribution channel, or benefit design — but the issue-year boundary is absolute. Every cohort carries its own net premium ratio, its own history of assumption unlocks, and its own remeasurement trail. Get the grouping wrong and every downstream number inherits the error: the ratio is computed over the wrong population, the catch-up adjustment lands in the wrong period, and the disclosure roll-forwards no longer tie.

That is why cohort management is an engineering problem before it is an accounting one. The population is seriatim and large, the issue-year assignment must be bit-for-bit stable across quarterly valuations, and the remeasurement has to reproduce prior-period figures exactly so an examiner can replay them. A cohort model that reassigns policies as data is refreshed, or that silently drops a mid-year issue across a boundary, breaks the audit trail no matter how correct the actuarial mathematics is.

Architecture of the Cohort Model

The model is a two-stage pipeline with an immutable ledger hanging off the second stage. Stage one is assignment: seriatim policy records resolve to a deterministic cohort_id built from issue year, product, and segment. Stage two is remeasurement: each cohort computes its net premium ratio against experience-to-date and updated forward assumptions, then posts the change as a period adjustment. The diagram above traces that flow — policies fan into annual issue-year cohorts, each carrying its own ratio and unlock history, and converge into a single liability-for-future-policy-benefits remeasurement.

The governing arithmetic is the net premium ratio (NPR). For a cohort, it is the present value of expected benefits and directly attributable expenses divided by the present value of expected gross premiums, measured at the original (locked-in) discount rate for the liability itself:

NPR  =  PV ⁣[benefits+expenses]PV ⁣[gross premiums]\text{NPR} \;=\; \frac{\text{PV}\!\left[\text{benefits} + \text{expenses}\right]}{\text{PV}\!\left[\text{gross premiums}\right]}

The ratio is capped at 100%; a cohort whose expected benefits exceed its premiums cannot carry a negative reserve forward, so the excess is recognized immediately. On each reporting date the NPR is recomputed using actual experience through the valuation date blended with revised future assumptions, and the liability is restated to what it would have been had that ratio always applied. The difference between the restated liability and the carried liability is the remeasurement gain or loss, which flows to current-period income — a catch-up adjustment. Discount-rate changes are handled separately: the liability is also revalued at the current upper-medium-grade (single-A) rate, and that portion of the change is recognized in other comprehensive income rather than income.

Each cohort therefore needs to persist three things between valuations: its locked-in discount curve, its most recent NPR and the experience vintage behind it, and the append-only sequence of remeasurements. That persistence is what makes the run reproducible, and it is why the model writes to an immutable record rather than mutating state in place. The same integrity discipline that governs Actuarial Audit Trail Architecture applies here: a cohort’s remeasurement history is evidence, and evidence is never overwritten.

Prerequisites

Before building the cohort model, the following should be in place:

  • Python 3.11+, for dataclasses, datetime handling, and modern typing.
  • Core packages: pandas and numpy for the seriatim and projection work, pydantic for record validation, and pyarrow if cohorts are persisted to Parquet between cycles.
  • A validated seriatim contract: every policy must carry a typed issue_date, product_code, segment, face_amount, and premium and benefit cash-flow vectors before it reaches assignment. Enforce that boundary with Schema Validation with Pydantic & Great Expectations so a malformed issue date can never silently land a policy in the wrong year.
  • A locked-in discount curve per cohort recorded at inception (or at transition), plus the current-rate curve for the OCI leg.
  • Conceptual grounding in the parent IFRS 17 & LDTI Filing Automation domain, and awareness that the same cohorts feed the deferred-profit machinery in Contractual Service Margin Automation when the carrier also reports under IFRS 17.
  • A regulatory frame: ASU 2018-12 and the amended ASC 944-40, with effective dates of fiscal years beginning after 15 December 2022 for SEC filers other than smaller reporting companies, and after 15 December 2024 for all other entities.

Core Implementation

The heart of the model is a cohort object that owns its ratio and its remeasurement ledger, plus an assignment function that derives a stable cohort_id. The remeasurement recomputes the NPR, restates the liability, and appends the catch-up rather than mutating the prior figure.

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date

import numpy as np
import pandas as pd


def assign_cohort_id(issue_date: date, product_code: str, segment: str) -> str:
    """Deterministic annual issue-year cohort key. Issue year is absolute."""
    return f"{issue_date.year:04d}-{product_code}-{segment}"


def present_value(cash_flows: np.ndarray, discount_curve: np.ndarray) -> float:
    """PV of a cash-flow vector along a per-period spot discount curve."""
    periods = np.arange(1, cash_flows.size + 1)
    factors = 1.0 / np.power(1.0 + discount_curve, periods)
    return float(np.sum(cash_flows * factors))


@dataclass
class Remeasurement:
    valuation_date: date
    experience_vintage: str
    net_premium_ratio: float
    restated_liability: float
    catch_up_to_income: float


@dataclass
class Cohort:
    cohort_id: str
    issue_year: int
    locked_in_curve: np.ndarray          # discount curve fixed at inception
    carried_liability: float = 0.0
    net_premium_ratio: float = 0.0
    history: list[Remeasurement] = field(default_factory=list)

    def remeasure(
        self,
        valuation_date: date,
        experience_vintage: str,
        benefits: np.ndarray,
        expenses: np.ndarray,
        gross_premiums: np.ndarray,
        pv_future_benefits: float,
    ) -> Remeasurement:
        """Recompute the NPR on updated assumptions and post the catch-up."""
        pv_out = present_value(benefits + expenses, self.locked_in_curve)
        pv_prem = present_value(gross_premiums, self.locked_in_curve)
        # NPR is capped at 100%: a cohort cannot carry a negative reserve forward.
        ratio = min(pv_out / pv_prem, 1.0) if pv_prem > 0 else 1.0

        # Restated LFPB = PV(future benefits) - NPR * PV(future gross premiums).
        pv_future_premiums = present_value(gross_premiums, self.locked_in_curve)
        restated = max(pv_future_benefits - ratio * pv_future_premiums, 0.0)

        catch_up = restated - self.carried_liability
        record = Remeasurement(
            valuation_date=valuation_date,
            experience_vintage=experience_vintage,
            net_premium_ratio=ratio,
            restated_liability=restated,
            catch_up_to_income=catch_up,
        )
        self.net_premium_ratio = ratio
        self.carried_liability = restated
        self.history.append(record)          # append-only ledger, never overwrite
        return record


def build_cohorts(seriatim: pd.DataFrame) -> dict[str, list[str]]:
    """Group seriatim policies into annual issue-year cohorts."""
    seriatim = seriatim.copy()
    seriatim["cohort_id"] = [
        assign_cohort_id(d, p, s)
        for d, p, s in zip(
            seriatim["issue_date"], seriatim["product_code"], seriatim["segment"]
        )
    ]
    grouped = seriatim.groupby("cohort_id")["policy_id"].apply(list)
    return grouped.to_dict()

The append-only history list is deliberate. Each remeasure call adds a Remeasurement and never edits an earlier one, so the ledger is a faithful record of how the cohort’s ratio and liability moved from period to period — exactly what the disclosure roll-forwards reconstruct. For the assignment step alone, in a runnable, edge-case-hardened form, see Grouping LDTI Cohorts by Issue Year in Python.

Configuration and Tuning

Two configuration choices dominate LDTI cohort behavior: the transition method used to seed the opening balances, and the granularity of the segment key inside each issue year. Both are recorded configuration, not code, because both change the reported numbers and both must be stable across a filing cycle.

from dataclasses import dataclass


@dataclass(frozen=True)
class CohortConfig:
    transition_method: str        # "modified_retrospective" or "full_retrospective"
    transition_date: str          # ISO date of the LDTI transition
    segment_keys: tuple[str, ...] # extra grouping dims inside an issue year
    npr_cap: float = 1.0          # net premium ratio ceiling (100%)


def opening_liability(config: CohortConfig, carrying_amount: float,
                      inception_npr: float | None) -> float:
    """Seed a cohort's opening LFPB per the chosen transition method."""
    if config.transition_method == "modified_retrospective":
        # Calibrate so the net liability at transition equals the existing
        # carrying amount; assumptions are the transition-date estimates.
        return carrying_amount
    if config.transition_method == "full_retrospective":
        if inception_npr is None:
            raise ValueError("full retrospective requires an inception NPR")
        # Rebuild from issue-year inception using original contract data.
        return carrying_amount  # placeholder: replaced by inception roll-forward
    raise ValueError(f"unknown transition method: {config.transition_method!r}")

Under the modified retrospective method — the default in ASU 2018-12 — a cohort’s net premium ratio is calibrated at the transition date so the recalculated net liability equals the existing carrying amount. There is no cumulative-effect adjustment to opening retained earnings for the liability itself (the discount-rate remeasurement is captured in accumulated OCI), which keeps the transition disclosures compact but permanently anchors the ratio to a transition-date starting point rather than to the contract’s real inception economics. Under the full retrospective method, each cohort is rebuilt from issue-year inception using original assumptions and actual historical experience, producing a cumulative-effect adjustment to the earliest period presented. It is more faithful and more disclosure-rich, but it demands inception-vintage data that many blocks simply do not retain — which is why most carriers elected modified retrospective for the bulk of their in-force. Record the elected method on every cohort; a block that mixes methods without labeling them cannot be reconciled.

The segment_keys tuple is the second lever. Finer segmentation (channel, issue-age band, rider) produces cohorts that behave more homogeneously and unlock more cleanly, but multiplies the number of ratios to track and the number of small cohorts vulnerable to noisy experience. Choose it once, record it, and keep it fixed — re-segmenting mid-life reshuffles the population across cohorts and severs the remeasurement history.

Step-by-Step

  1. Validate the seriatim boundary. Confirm every policy has a typed issue_date, product_code, and segment before assignment, rejecting malformed records at the edge rather than mis-cohorting them.
  2. Derive a stable cohort_id. Combine issue year, product, and segment into a deterministic key, and treat the issue-year component as absolute — no policy ever crosses a calendar-year boundary.
  3. Seed opening balances by transition method. For modified retrospective, calibrate each cohort’s NPR so the net liability equals the transition-date carrying amount; for full retrospective, roll forward from issue-year inception.
  4. Record the locked-in discount curve. Persist the inception (or transition-date) curve per cohort for the liability leg, and keep the current-rate curve separately for the OCI revaluation.
  5. Refresh cash-flow assumptions. At least annually, review mortality, lapse, morbidity, and expense assumptions and update the ones that changed, keeping the prior set for comparison.
  6. Recompute the net premium ratio. Blend actual experience through the valuation date with the updated forward assumptions, recompute the ratio, and apply the 100% cap.
  7. Restate the liability and post the catch-up. Restate the liability to the recalculated ratio, recognize the difference against income, and append the remeasurement to the cohort’s ledger.
  8. Revalue for the discount-rate change. Revalue the liability at the current single-A rate and route that portion of the movement to other comprehensive income.
  9. Emit the roll-forward. Assemble the per-cohort disclosure roll-forward from the append-only history so each reported balance ties to a specific ratio and vintage.

Validation and Testing

Correctness for a cohort model means three invariants hold: assignment is deterministic across runs, the remeasurement reproduces prior periods exactly, and no policy ever crosses an issue-year boundary. Assert all three.

import pandas as pd


def test_cohort_id_is_deterministic():
    """Same inputs must always yield the same cohort_id, run to run."""
    from datetime import date
    a = assign_cohort_id(date(2022, 6, 30), "TERM20", "RETAIL")
    b = assign_cohort_id(date(2022, 6, 30), "TERM20", "RETAIL")
    assert a == b == "2022-TERM20-RETAIL"


def test_no_policy_crosses_issue_year(sample_seriatim: pd.DataFrame):
    """Every cohort must contain exactly one issue year."""
    cohorts = build_cohorts(sample_seriatim)
    for cohort_id in cohorts:
        year = cohort_id.split("-")[0]
        assert year.isdigit() and len(year) == 4


def test_remeasurement_history_is_append_only(sample_cohort: Cohort):
    """Each remeasurement extends the ledger; none is overwritten."""
    before = len(sample_cohort.history)
    sample_cohort.remeasure(
        valuation_date=pd.Timestamp("2026-12-31").date(),
        experience_vintage="2026Q4",
        benefits=sample_cohort_benefits(),
        expenses=sample_cohort_expenses(),
        gross_premiums=sample_cohort_premiums(),
        pv_future_benefits=1_050_000.0,
    )
    assert len(sample_cohort.history) == before + 1

Beyond unit tests, reconcile the sum of cohort-level liabilities to the aggregate LFPB the general ledger expects, and run a Great Expectations checkpoint over the remeasurement table to assert no null ratios, ratios within [0, 1], and a catch-up that nets to the disclosed remeasurement line. Drift in those aggregate ratios across cycles is a signal worth monitoring, not just a number to file.

Failure Modes and Gotchas

  • Silent cohort reassignment. Re-deriving cohort_id from a refreshed data extract that changed a policy’s product code or segment moves it to a different cohort and orphans its history. Freeze assignment at inception and treat any later change as a documented, audited exception.
  • Mid-year issue drift across the boundary. A time-zone or parsing bug that reads a 31 December issue as 1 January pushes a policy into the next year’s cohort. Parse issue_date as a date, never a naive timestamp, and test the boundary explicitly.
  • Uncapped net premium ratio. Omitting the 100% cap lets an onerous cohort carry a negative reserve forward, understating the liability and deferring a loss the standard requires to be recognized immediately.
  • Blending the discount legs. Recognizing the discount-rate remeasurement in income instead of OCI, or using the current rate for the NPR liability instead of the locked-in rate, misstates both income and comprehensive income. Keep the locked-in and current-rate curves strictly separate.
  • Overwriting the remeasurement ledger. Mutating a prior Remeasurement in place — to “fix” a restated figure — destroys the reproducibility the roll-forward depends on. Append a correcting entry instead.
  • Mixed transition methods without labels. A block that seeds some cohorts modified-retrospective and others full-retrospective, without recording which is which, cannot be reconciled or explained under examination.

How LDTI Cohorting Differs from IFRS 17 Grouping

Both regimes group by annual issue vintage, but they are not the same rule and they should not share a code path blindly. IFRS 17 requires groups of contracts issued no more than one year apart, then subdivides each portfolio by profitability at inception — onerous, no significant possibility of becoming onerous, and the remainder — and tracks a contractual service margin that releases profit over coverage units. LDTI groups by issue year and product but imposes no profitability subdivision and carries no CSM; its deferred profit lives in the net premium ratio and its remeasurement flows through income as a catch-up. A carrier reporting under both will find the issue-year skeleton reusable, but the buckets hanging off it, the discount-rate treatment, and the profit mechanics diverge. The shared cohort key is a convenience; the measurement models behind it are distinct, and conflating them is a fast route to a reconciliation that never ties.

Frequently Asked Questions

What defines an LDTI cohort under ASU 2018-12?

An LDTI cohort is a group of long-duration contracts issued in the same calendar year and sharing a product type and similar characteristics. Contracts issued in different years may never be combined, so the issue-year boundary is the hard rule and product or segment is the finer grouping within it.

How often must LDTI cash-flow assumptions be updated?

Cash-flow assumptions such as mortality, lapse, and morbidity must be reviewed at least annually and updated whenever they change, with the liability remeasured retrospectively through a recalculated net premium ratio. Discount-rate assumptions are updated every reporting date, with that portion of the change recognized in other comprehensive income.

What is the difference between the modified and full retrospective transition methods?

Modified retrospective calibrates each cohort’s net premium ratio so the net liability at the transition date equals the existing carrying amount, with no cumulative-effect adjustment to retained earnings for the liability. Full retrospective rebuilds each cohort from issue-year inception using original data, producing a cumulative-effect adjustment but requiring historical experience many blocks no longer retain.

Why must cohort assignment be reproducible?

Because the cohort is the unit of account, every reported figure and disclosure roll-forward depends on each policy resolving to the same cohort every valuation. Non-deterministic assignment reshuffles the population, breaks the remeasurement history, and leaves an examiner unable to replay prior-period numbers.

Up a level: 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).