Semaglutide Weight Loss Curve (NEJM 2021)

Background

This figure is a reproduction of Figure 1A from the landmark study "Once-Weekly Semaglutide in Adults with Overweight or Obesity", published in The New England Journal of Medicine (NEJM) in 2021. The study evaluates the efficacy and safety of semaglutide as a pharmacological intervention for weight management.

The plot illustrates the mean percentage change in body weight over a 68-week period. It highlights the significant divergence in weight loss trajectories between the semaglutide group and the placebo group, both of which were conducted alongside lifestyle interventions.

Data Acquisition

The data used for this visualization was extracted from the original publication using WebPlotDigitizer.

Implementation

Using Charton’s "Grammar of Graphics" approach, we can recreate this complex clinical plot by layering multiple graphical components, enabling highly flexible and customizable visualizations with concise Rust code.

use charton::prelude::*;
use polars::prelude::*;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    // Placebo group data (control group)
    let df_placebo = df![
        // X-axis: time since randomization (weeks)
        "Weeks since Randomization" => [0, 4, 8, 12, 16, 20, 28, 36, 44, 52, 60, 68],
        // Y-axis: mean percentage change in body weight from baseline. Negative values indicate weight loss
        "Change from Baseline (%)" => [0.00, -1.11, -1.72, -2.18, -2.54, -2.83, -2.82, -2.98, -3.24, -3.31, -3.22, -2.76],
        // Lower bound of the confidence interval (typically 95% CI)
        "lower" => [-0.042, -1.18, -1.81, -2.28, -2.66, -3.00, -3.03, -3.22, -3.49, -3.54, -3.46, -3.03],
        // Upper bound of the confidence interval (95% CI)
        "upper" => [0.042, -1.04, -1.63, -2.08, -2.42, -2.66, -2.61, -2.74, -2.99, -3.08, -2.98, -2.49]
    ].unwrap();

    // Semaglutide group data (treatment group)
    let df_semaglutide = df![
        "Weeks since Randomization" => [0, 4, 8, 12, 16, 20, 28, 36, 44, 52, 60, 68],
        "Change from Baseline (%)" => [0.00, -2.27, -4.01, -5.9, -7.66, -9.46, -11.68, -13.33, -14.62, -15.47, -15.86, -15.6],
        "lower" => [-0.041, -2.3, -4.1, -5.98, -7.79, -9.58, -11.84, -13.55, -14.83, -15.72, -16.13, -15.86],
        "upper" => [0.041, -2.24, -3.92, -5.82, -7.53, -9.34, -11.52, -13.11, -14.41, -15.22, -15.59, -15.34]
    ].unwrap();

    // Text labels (placed at the right side of the plot)
    let df_text =
        df!["x" => [68.8, 68.8], "y" => [-3.05, -15.86], "group" => ["Placebo", "Semaglutide"]]?;

    // Reference line (y = 0 → no weight change)
    let df_reference = df!["x" => [0.0, 68.0], "y" => [0.0, 0.0]]?;

    // Layer 1: Placebo points (markers at each time point)
    let placebo_point = Chart::build(&df_placebo)?
        .mark_point()?
        .configure_point(|p| {
            p.with_color("#818284")
                .with_shape("triangle")
                .with_size(5.0)
        })
        .encode((
            x("Weeks since Randomization"),
            y("Change from Baseline (%)"),
        ))?;

    // Layer 2: Placebo line (connects the points)
    let placebo_line = Chart::build(&df_placebo)?
        .mark_line()?
        .configure_line(|l| l.with_color("#818284"))
        .encode((
            x("Weeks since Randomization"),
            y("Change from Baseline (%)"),
        ))?;

    // Layer 3: Placebo error bars (confidence intervals)
    let placebo_errorbar = Chart::build(&df_placebo)?
        .mark_errorbar()?
        .configure_errorbar(|e| {
            e.with_color("#818284")
                .with_cap_length(4.0)
                .with_stroke_width(1.5)
        })
        .encode((x("Weeks since Randomization"), y("lower"), y2("upper")))?;

    // Layer 4: Placebo text label
    let placebo_text = Chart::build(&df_text.head(Some(1)))?
        .mark_text()?
        .configure_text(|t| t.with_anchor("left").with_size(14.0))
        .encode((x("x"), y("y"), text("group")))?;

    // Layer 5: Semaglutide points
    let semaglutide_point = Chart::build(&df_semaglutide)?
        .mark_point()?
        .configure_point(|p| p.with_color("#5b88c3").with_shape("square").with_size(3.0))
        .encode((
            x("Weeks since Randomization"),
            y("Change from Baseline (%)"),
        ))?;

    // Layer 6: Semaglutide line
    let semaglutide_line = Chart::build(&df_semaglutide)?
        .mark_line()?
        .configure_line(|l| l.with_color("#5b88c3"))
        .encode((
            x("Weeks since Randomization"),
            y("Change from Baseline (%)"),
        ))?;

    // Layer 7: Semaglutide error bars
    let semaglutide_errorbar = Chart::build(&df_semaglutide)?
        .mark_errorbar()?
        .configure_errorbar(|e| {
            e.with_color("#5b88c3")
                .with_cap_length(4.0)
                .with_stroke_width(1.5)
        })
        .encode((x("Weeks since Randomization"), y("lower"), y2("upper")))?;

    // Layer 8: Semaglutide text label
    let semaglutide_text = Chart::build(&df_text.tail(Some(1)))?
        .mark_text()?
        .configure_text(|t| t.with_anchor("left").with_size(14.0))
        .encode((x("x"), y("y"), text("group")))?;

    // Layer 9: Reference line (baseline at 0%)
    let reference_line = Chart::build(&df_reference)?
        .mark_line()?
        .configure_line(|l| l.with_dash([6.0, 6.0]))
        .encode((x("x"), y("y")))?;

    // Combine all layers (Grammar of Graphics composition)
    placebo_point
        .and(reference_line)
        .and(placebo_line)
        .and(placebo_errorbar)
        .and(placebo_text)
        .and(semaglutide_point)
        .and(semaglutide_line)
        .and(semaglutide_errorbar)
        .and(semaglutide_text)
        .with_x_expand(Expansion {
            mult: (0.00, 0.02),
            add: (0.0, 0.0),
        })
        .with_y_expand(Expansion {
            mult: (0.15, 0.01),
            add: (0.0, 0.0),
        })
        .with_size(1000, 400)
        .with_right_margin(0.08)
        .with_left_margin(0.02)
        .with_top_margin(0.02)
        .with_bottom_margin(0.03)
        .with_x_ticks([
            0.0, 4.0, 8.0, 12.0, 16.0, 20.0, 28.0, 36.0, 44.0, 52.0, 60.0, 68.0,
        ])
        .with_y_ticks([
            0.0, -2.0, -4.0, -6.0, -8.0, -10.0, -12.0, -14.0, -16.0, -18.0,
        ])
        .save("docs/src/images/weight_loss_curve.svg")?;

    Ok(())
}