Treatment

vivarium_public_health provides components for modeling treatment interventions in public health simulations. This tutorial covers the treatment package - how to model intervention coverage, how interventions reduce disease rates, and how to apply direct shifts or scale-ups to epidemiological measures.

For how risk factor exposures modify disease outcomes, see the Risk Exposure and Risk Effects tutorials.

Overview

The treatment package provides several components for modeling interventions:

Intervention - a dichotomous coverage model that assigns each simulant a covered or uncovered status. Intervention is the treatment analogue of Risk.

InterventionEffect - how intervention coverage modifies a target rate or measure (via a relative risk). InterventionEffect is the treatment analogue of RiskEffect.

AbsoluteShift - a simple component that directly replaces a target measure with a configured value for simulants in a specified age range. AbsoluteShift

LinearScaleUp - linearly interpolates intervention coverage between a start and end value over a configured date range. LinearScaleUp

TherapeuticInertia - draws a population-level therapeutic inertia value from a triangular distribution, representing the probability that treatment is not escalated during a healthcare visit. TherapeuticInertia

Common Setup

Every code example in this tutorial uses the imports and helpers shown below. To run any example in a standalone script, include all of these at the top:

from vivarium import InteractiveContext
from vivarium_public_health.treatment import (
    Intervention, InterventionEffect, AbsoluteShift,
    LinearScaleUp, TherapeuticInertia,
)
from vivarium_public_health.disease import SI, SIS
from vivarium_public_health.population import BasePopulation
from vivarium_public_health._example_data import (
    BASE_PLUGINS, ConstantRatePipeline, make_base_config,
)

# BASE_PLUGINS overrides the data plugin to use ExampleArtifactManager,
# which serves example data from memory instead of requiring a real HDF file.
base_plugins = BASE_PLUGINS

# make_base_config() returns a configuration with sensible defaults for
# time range, step size, and randomness key columns.
config = make_base_config()

Intervention

An Intervention component assigns each simulant a coverage status - either "covered" or "uncovered". The proportion covered is determined by the exposure data source. Each simulant’s propensity (a random value drawn at initialization) determines whether they receive coverage.

Intervention is a specialization of CausalFactor restricted to the "intervention" entity type.

The configuration key for an intervention is its full entity string (e.g., intervention.my_treatment):

configuration:
  intervention.my_treatment:
    distribution_type: "dichotomous"
    data_sources:
      exposure: 0.5  # scalar, DataFrame, callable, or artifact key

Basic example

The simplest intervention model sets everyone to covered:

config = make_base_config()
config.update(
    {
        "population": {"population_size": 1_000},
        "mortality": {"data_sources": {"all_cause_mortality_rate": 0}},
        "intervention.my_treatment": {
            "distribution_type": "dichotomous",
            "data_sources": {"exposure": 1.0},
        },
    },
    layer="override",
)

sim = InteractiveContext(
    components=[
        BasePopulation(),
        Intervention("intervention.my_treatment"),
    ],
    configuration=config,
    plugin_configuration=base_plugins,
)

pop = sim.get_population(["my_treatment.exposure"])
# With exposure=1.0, all simulants are covered by the intervention.
print(f"All covered: {(pop['my_treatment.exposure'] == 'covered').all()}")
All covered: True

Partial coverage

When the coverage proportion is less than 1, some simulants will be covered and others will not. The split is determined by each simulant’s propensity draw:

config = make_base_config()
config.update(
    {
        "population": {"population_size": 10_000},
        "mortality": {"data_sources": {"all_cause_mortality_rate": 0}},
        "intervention.my_treatment": {
            "distribution_type": "dichotomous",
            "data_sources": {"exposure": 0.4},
        },
    },
    layer="override",
)

sim = InteractiveContext(
    components=[
        BasePopulation(),
        Intervention("intervention.my_treatment"),
    ],
    configuration=config,
    plugin_configuration=base_plugins,
)

pop = sim.get_population(["my_treatment.exposure"])
n_covered = (pop["my_treatment.exposure"] == "covered").sum()
proportion = n_covered / len(pop)
# Approximately 40% should be covered.
print(f"Proportion covered near 0.4: {np.isclose(proportion, 0.4, atol=0.02)}")
Proportion covered near 0.4: True

InterventionEffect

An InterventionEffect modifies disease dynamics based on intervention coverage. Unlike a risk factor (where exposed simulants typically have a higher rate), an intervention typically reduces the target rate for covered simulants (relative risk < 1).

InterventionEffect is a specialization of CausalFactorEffect for interventions. Its configuration key combines the intervention name and the target:

configuration:
  intervention_effect.{intervention_name}_on_{target_entity}.{target_name}.{target_measure}:
    data_sources:
      relative_risk: 0.5           # scalar, DataFrame, callable, or artifact key
      population_attributable_fraction: 0  # typically 0 for interventions

Reducing disease incidence

The following example demonstrates that covered simulants become infected at a lower rate than uncovered simulants:

config = make_base_config()
config.update(
    {
        "population": {"population_size": 10_000},
        "mortality": {"data_sources": {"all_cause_mortality_rate": 0}},
        "intervention.my_treatment": {
            "distribution_type": "dichotomous",
            "data_sources": {"exposure": 0.5},
        },
        "intervention_effect.my_treatment_on_cause.test_cause.incidence_rate": {
            "data_sources": {
                "relative_risk": 0.2,
                "population_attributable_fraction": 0,
            },
        },
    },
    layer="override",
)

sim = InteractiveContext(
    components=[
        BasePopulation(),
        Intervention("intervention.my_treatment"),
        InterventionEffect(
            "intervention.my_treatment", "cause.test_cause.incidence_rate"
        ),
        SI("test_cause"),
    ],
    configuration=config,
    plugin_configuration=base_plugins,
)

# Step forward to allow infections to occur.
for _ in range(3):
    sim.step()

pop = sim.get_population(["test_cause", "my_treatment.exposure"])
covered = pop[pop["my_treatment.exposure"] == "covered"]
uncovered = pop[pop["my_treatment.exposure"] == "uncovered"]

covered_infection_rate = (covered["test_cause"] == "test_cause").sum() / len(
    covered
)
uncovered_infection_rate = (uncovered["test_cause"] == "test_cause").sum() / len(
    uncovered
)

# With RR=0.2, covered simulants should have ~1/5 the infection rate.
ratio = covered_infection_rate / uncovered_infection_rate
print(f"Rate ratio near 0.2: {np.isclose(ratio, 0.2, rtol=0.15)}")
Rate ratio near 0.2: True

AbsoluteShift

An AbsoluteShift provides a direct override of a target epidemiological measure. When target_value is set to a numeric value, the component replaces the target pipeline’s value for all simulants within the configured age range. When target_value is "baseline", no modification is applied - this lets you use the same model specification for baseline and intervention scenarios by switching a single config value.

The target is specified at instantiation as a string in the form "entity_type.entity_name.measure" (e.g., "cause.test_cause.incidence_rate").

Configuration

configuration:
  # Config key is intervention_on_{entity_name}
  intervention_on_test_cause:
    target_value: 0.0       # numeric value or "baseline"
    age_start: 0            # minimum age for effect
    age_end: 125            # maximum age for effect

Eliminating disease incidence

The following example replaces a rate pipeline’s value using AbsoluteShift. We use a ConstantRatePipeline to create a simple attribute pipeline with value 0.5, then override it with 0.3:

config = make_base_config()
config.update(
    {
        "population": {"population_size": 1_000},
        "mortality": {"data_sources": {"all_cause_mortality_rate": 0}},
        "intervention_on_test_cause": {
            "target_value": 0.3,
            "age_start": 0,
            "age_end": 125,
        },
    },
    layer="override",
)

sim = InteractiveContext(
    components=[
        BasePopulation(),
        ConstantRatePipeline("test_cause.incidence_rate", rate=0.5),
        AbsoluteShift("cause.test_cause.incidence_rate"),
    ],
    configuration=config,
    plugin_configuration=base_plugins,
)

# The pipeline value should be replaced with 0.3 for all simulants.
pop = sim.get_population("test_cause.incidence_rate")
print(f"All values replaced: {(pop == 0.3).all()}")
All values replaced: True

Age-targeted intervention

AbsoluteShift supports targeting specific age ranges. The following example sets the rate to 0.1 only for simulants aged 15-50, while others retain the original value of 0.5:

config = make_base_config()
config.update(
    {
        "population": {"population_size": 10_000},
        "mortality": {"data_sources": {"all_cause_mortality_rate": 0}},
        "intervention_on_test_cause": {
            "target_value": 0.1,
            "age_start": 15,
            "age_end": 50,
        },
    },
    layer="override",
)

sim = InteractiveContext(
    components=[
        BasePopulation(),
        ConstantRatePipeline("test_cause.incidence_rate", rate=0.5),
        AbsoluteShift("cause.test_cause.incidence_rate"),
    ],
    configuration=config,
    plugin_configuration=base_plugins,
)

pop = sim.get_population(["test_cause.incidence_rate", "age"])
in_range = pop[(pop["age"] >= 15) & (pop["age"] <= 50)]
outside_range = pop[(pop["age"] < 15) | (pop["age"] > 50)]
# Two distinct values coexist: replaced (0.1) and original (0.5).
print(f"In-range values replaced: {(in_range['test_cause.incidence_rate'] == 0.1).all()}")
print(f"Outside-range values unchanged: {(outside_range['test_cause.incidence_rate'] == 0.5).all()}")
In-range values replaced: True
Outside-range values unchanged: True

LinearScaleUp

A LinearScaleUp linearly interpolates an intervention’s coverage between a start value and an end value over a configured date range. Before the start date, the start value applies; after the end date, the end value applies. It works by modifying the exposure_parameters pipeline of an Intervention component.

The LinearScaleUp component checks whether the simulation is running an intervention scenario (configuration.intervention.scenario != "baseline"). If the scenario is "baseline", no scale-up is applied.

Configuration

The configuration specifies dates and endpoint values:

configuration:
  # Enable intervention scenario
  intervention:
    scenario: "treatment"

  # Intervention coverage (initial value before scale-up)
  intervention.my_treatment:
    distribution_type: "dichotomous"
    data_sources:
      exposure: 0.2

  # Scale-up configuration
  my_treatment_scale_up:
    date:
      start: "1993-01-01"
      end: "1997-01-01"
    value:
      start: 0.2   # matches initial coverage
      end: 0.8     # target coverage after scale-up

The value.start and value.end can be numeric scalars or the string "data" to load endpoint values from the artifact.

Scale-up behavior

The scale-up modifies the intervention’s exposure_parameters pipeline by adding:

\[\text{adjustment} = \text{progress} \times (\text{end\_value} - \text{start\_value})\]

where \(\text{progress}\) is 0 before the start date, 1 after the end date, and linearly interpolated between them.

The following example demonstrates coverage increasing from 0% to 100% over the scale-up period:

config = make_base_config()
config.update(
    {
        "population": {"population_size": 10_000},
        "mortality": {"data_sources": {"all_cause_mortality_rate": 0}},
        "intervention": {"scenario": "treatment"},
        "intervention.my_treatment": {
            "distribution_type": "dichotomous",
            "data_sources": {"exposure": 0.0},
        },
        "my_treatment_scale_up": {
            "date": {
                "start": "1990-07-01",
                "end": "1995-07-01",
            },
            "value": {
                "start": 0.0,
                "end": 1.0,
            },
        },
    },
    layer="override",
)

sim = InteractiveContext(
    components=[
        BasePopulation(),
        Intervention("intervention.my_treatment"),
        LinearScaleUp("intervention.my_treatment"),
    ],
    configuration=config,
    plugin_configuration=base_plugins,
)

# At the start (before scale-up midpoint), coverage should be low.
pop_early = sim.get_population(["my_treatment.exposure"])
coverage_early = (pop_early["my_treatment.exposure"] == "covered").sum() / len(
    pop_early
)

# Step to the end of the scale-up period (each step ~30.5 days).
# ~65 steps = ~5.4 years, past the end date of 1995-07-01.
for _ in range(65):
    sim.step()

pop_late = sim.get_population(["my_treatment.exposure"])
coverage_late = (pop_late["my_treatment.exposure"] == "covered").sum() / len(
    pop_late
)

# Coverage should have increased substantially.
print(f"Coverage increased: {coverage_late > coverage_early}")
# After the end date, coverage should be near 100%.
print(f"Full coverage achieved: {np.isclose(coverage_late, 1.0, atol=0.01)}")
Coverage increased: True
Full coverage achieved: True

TherapeuticInertia

TherapeuticInertia models the variety of reasons why a treatment algorithm might deviate from clinical guidelines. At setup, a single scalar value is drawn from a triangular distribution and exposed via the therapeutic_inertia pipeline. This value represents the probability that treatment is not escalated during a healthcare visit.

Configuration

configuration:
  therapeutic_inertia:
    triangle_min: 0.65
    triangle_max: 0.9
    triangle_mode: 0.875

Basic usage

config = make_base_config()
config.update(
    {
        "population": {"population_size": 100},
        "mortality": {"data_sources": {"all_cause_mortality_rate": 0}},
        "therapeutic_inertia": {
            "triangle_min": 0.65,
            "triangle_max": 0.9,
            "triangle_mode": 0.875,
        },
    },
    layer="override",
)

sim = InteractiveContext(
    components=[
        BasePopulation(),
        TherapeuticInertia(),
    ],
    configuration=config,
    plugin_configuration=base_plugins,
)

# The therapeutic inertia value is a single scalar applied to all simulants.
pop = sim.get_population()
ti_values = sim.get_value("therapeutic_inertia")(pop.index)
# All simulants have the same inertia value (population-level draw).
unique_values = ti_values.unique()
print(f"Single population-level value: {len(unique_values) == 1}")
# The value should be within the configured triangle bounds.
ti = unique_values[0]
print(f"Within bounds: {0.65 <= ti <= 0.9}")
Single population-level value: True
Within bounds: True

Configuration Summary

Component

Key configuration options

Purpose

Intervention

intervention.{name}.distribution_type, intervention.{name}.data_sources.exposure

Assign dichotomous coverage to simulants

InterventionEffect

intervention_effect.{name}_on_{target}.data_sources.relative_risk, intervention_effect.{name}_on_{target}.data_sources.population_attributable_fraction

Modify target rate based on coverage

AbsoluteShift

intervention_on_{target_name}.target_value, intervention_on_{target_name}.age_start, intervention_on_{target_name}.age_end

Replace a measure with a fixed value

LinearScaleUp

{name}_scale_up.date.start, {name}_scale_up.date.end, {name}_scale_up.value.start, {name}_scale_up.value.end

Linearly ramp coverage over time

TherapeuticInertia

therapeutic_inertia.triangle_min, therapeutic_inertia.triangle_max, therapeutic_inertia.triangle_mode

Draw a population-level inertia scalar

Note

Intervention and InterventionEffect are specializations of the more general CausalFactor and CausalFactorEffect base classes. For interventions, the exposure categories are "covered" and "uncovered" (rather than "exposed" / "unexposed" for risk factors).

For modeling risk factor exposures and effects, see the Risk Exposure and Risk Effects tutorials.