Skip to content

Visualization

ECG waveforms

plot_ecg_comparison

plot_ecg_comparison(real: ECGDataset, synthetics: List[ECGDataset], sample_index: int = 0, leads: Optional[List[str]] = None, mode: str = 'overlay', add_calibration_pulse: bool = True) -> Figure

Plot one ECG recording from real and the corresponding sample from each synthetic dataset on ECG graph paper.

Parameters:

Name Type Description Default
real ECGDataset
required
synthetics list of ECGDataset
required
sample_index int

Which recording to visualise (0-indexed).

0
leads list of str

Subset of lead names to show. Defaults to all leads.

None
mode 'overlay' | 'sidebyside' | 'grid'
'overlay'
add_calibration_pulse bool

Prepend a 1 mV × 0.2 s calibration square wave to every trace.

True

Returns:

Type Description
Figure
Source code in ecgen_eval/visualization/ecg_waveform.py
def plot_ecg_comparison(
    real: ECGDataset,
    synthetics: List[ECGDataset],
    sample_index: int = 0,
    leads: Optional[List[str]] = None,
    mode: str = "overlay",
    add_calibration_pulse: bool = True,
) -> go.Figure:
    """
    Plot one ECG recording from *real* and the corresponding sample from each
    synthetic dataset on ECG graph paper.

    Parameters
    ----------
    real : ECGDataset
    synthetics : list of ECGDataset
    sample_index : int
        Which recording to visualise (0-indexed).
    leads : list of str, optional
        Subset of lead names to show. Defaults to all leads.
    mode : 'overlay' | 'sidebyside' | 'grid'
    add_calibration_pulse : bool
        Prepend a 1 mV × 0.2 s calibration square wave to every trace.

    Returns
    -------
    go.Figure
    """
    if leads is None:
        leads = real.lead_names

    leads = [l for l in leads if l in real.lead_names]
    if not leads:
        raise ValueError("No valid lead names found in real dataset.")

    if mode == "overlay":
        return _overlay(real, synthetics, sample_index, leads, add_calibration_pulse)
    elif mode == "sidebyside":
        return _sidebyside(real, synthetics, sample_index, leads, add_calibration_pulse)
    elif mode == "grid":
        return _grid(real, synthetics, sample_index, leads, add_calibration_pulse)
    else:
        raise ValueError(f"Unknown mode: {mode!r}. Choose 'overlay', 'sidebyside', or 'grid'.")

ECG graph paper

ECG graph paper background builder.

Creates a Plotly Figure pre-configured with the traditional ECG paper look: - Pink/salmon background - Light-red minor grid (1 mm = 0.04 s × 0.1 mV at 25 mm/s, 10 mm/mV) - Dark-red major grid (5 mm = 0.20 s × 0.5 mV)

All other visualization modules call make_ecg_paper_figure() as a factory and add traces on top of the returned figure.

Reference for standard ECG paper dimensions: Kligfield, P., et al. (2007). Recommendations for the Standardization and Interpretation of the Electrocardiogram. Journal of the American College of Cardiology, 49(10), 1109–1127. https://doi.org/10.1016/j.jacc.2007.01.024

make_ecg_paper_figure

make_ecg_paper_figure(n_rows: int, n_cols: int, row_labels: Optional[List[str]] = None, col_labels: Optional[List[str]] = None, x_range: Optional[Tuple[float, float]] = None, y_range: Optional[Tuple[float, float]] = None, fs: float = 500.0, subplot_height_px: int = 180, subplot_width_px: int = 380, shared_xaxes: bool = True, shared_yaxes: bool = False, title: str = '') -> Figure

Build a Plotly Figure with ECG graph-paper styling for a grid of subplots.

Parameters:

Name Type Description Default
n_rows int

Subplot grid dimensions.

required
n_cols int

Subplot grid dimensions.

required
row_labels list of str

Axis-level labels added as annotations.

None
col_labels list of str

Axis-level labels added as annotations.

None
x_range (float, float)

X-axis range in seconds. Default derived from minor/major grid defaults.

None
y_range (float, float)

Y-axis range in mV. Default: (-1.5, 1.5).

None
fs float

Sampling frequency (used only for tick label formatting hints).

500.0
subplot_height_px int

Pixel size per subplot panel.

180
subplot_width_px int

Pixel size per subplot panel.

180
shared_xaxes bool

Passed to make_subplots.

True
shared_yaxes bool

Passed to make_subplots.

True
title str

Figure title.

''

Returns:

Type Description
Figure

Pre-styled figure; caller adds traces via fig.add_trace(..., row=r, col=c).

Source code in ecgen_eval/visualization/ecg_paper.py
def make_ecg_paper_figure(
    n_rows: int,
    n_cols: int,
    row_labels: Optional[List[str]] = None,
    col_labels: Optional[List[str]] = None,
    x_range: Optional[Tuple[float, float]] = None,
    y_range: Optional[Tuple[float, float]] = None,
    fs: float = 500.0,
    subplot_height_px: int = 180,
    subplot_width_px: int = 380,
    shared_xaxes: bool = True,
    shared_yaxes: bool = False,
    title: str = "",
) -> go.Figure:
    """
    Build a Plotly Figure with ECG graph-paper styling for a grid of subplots.

    Parameters
    ----------
    n_rows, n_cols : int
        Subplot grid dimensions.
    row_labels, col_labels : list of str, optional
        Axis-level labels added as annotations.
    x_range : (float, float), optional
        X-axis range in seconds. Default derived from minor/major grid defaults.
    y_range : (float, float), optional
        Y-axis range in mV. Default: (-1.5, 1.5).
    fs : float
        Sampling frequency (used only for tick label formatting hints).
    subplot_height_px, subplot_width_px : int
        Pixel size per subplot panel.
    shared_xaxes, shared_yaxes : bool
        Passed to make_subplots.
    title : str
        Figure title.

    Returns
    -------
    go.Figure
        Pre-styled figure; caller adds traces via fig.add_trace(..., row=r, col=c).
    """
    if y_range is None:
        y_range = (-1.6, 1.6)

    fig = make_subplots(
        rows=n_rows,
        cols=n_cols,
        shared_xaxes=shared_xaxes,
        shared_yaxes=shared_yaxes,
        vertical_spacing=0.06,
        horizontal_spacing=0.04,
        subplot_titles=_subplot_titles(row_labels, col_labels, n_rows, n_cols),
    )

    total_h = subplot_height_px * n_rows + 120
    total_w = subplot_width_px * n_cols + 80

    fig.update_layout(
        height=total_h,
        width=total_w,
        paper_bgcolor="white",
        plot_bgcolor=BG_COLOR,
        title_text=title,
        title_font_size=16,
        showlegend=True,
        legend=dict(
            orientation="h",
            yanchor="bottom",
            y=1.01,
            xanchor="right",
            x=1,
            font_size=12,
        ),
        margin=dict(l=60, r=30, t=80, b=60),
        hovermode="x unified",
    )

    # Apply ECG paper grid to every subplot axis
    for r in range(1, n_rows + 1):
        for c in range(1, n_cols + 1):
            axis_idx = "" if (r == 1 and c == 1) else str((r - 1) * n_cols + c)
            _style_axis(fig, axis_idx, x_range, y_range)

    return fig

get_synth_color

get_synth_color(index: int) -> str

Return a colour string for the i-th synthetic dataset.

Source code in ecgen_eval/visualization/ecg_paper.py
def get_synth_color(index: int) -> str:
    """Return a colour string for the i-th synthetic dataset."""
    return SYNTH_PALETTE[index % len(SYNTH_PALETTE)]

Metric plots

Plotly-based plots for metric results.

Provides: - Per-lead bar chart - Distribution violin (for DTW pairwise distances) - Radar chart summarising all metrics across datasets

plot_per_lead_bar

plot_per_lead_bar(results: Dict[str, MetricResult], metric_name: str, lead_names: Optional[List[str]] = None) -> Figure

Grouped bar chart: X = leads, bars = synthetic datasets.

Parameters:

Name Type Description Default
results dict mapping dataset_label → MetricResult
required
metric_name str (e.g. "MMD", "DTW")
required
lead_names list of str, optional — filter/order leads
None
Source code in ecgen_eval/visualization/metric_plots.py
def plot_per_lead_bar(
    results: Dict[str, MetricResult],
    metric_name: str,
    lead_names: Optional[List[str]] = None,
) -> go.Figure:
    """
    Grouped bar chart: X = leads, bars = synthetic datasets.

    Parameters
    ----------
    results : dict mapping dataset_label → MetricResult
    metric_name : str   (e.g. "MMD", "DTW")
    lead_names : list of str, optional — filter/order leads
    """
    fig = go.Figure()

    for i, (label, result) in enumerate(results.items()):
        per_lead = result.per_lead_scores
        if lead_names is None:
            leads = list(per_lead.keys())
        else:
            leads = [l for l in lead_names if l in per_lead]

        fig.add_trace(
            go.Bar(
                name=label,
                x=leads,
                y=[per_lead[l] for l in leads],
                marker_color=get_synth_color(i),
                opacity=0.85,
            )
        )

    direction = "lower" if not list(results.values())[0].higher_is_better else "higher"
    fig.update_layout(
        title=f"{metric_name} Score per Lead ({direction} = better)",
        xaxis_title="Lead",
        yaxis_title=f"{metric_name} Score",
        barmode="group",
        height=400,
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
        plot_bgcolor="white",
        paper_bgcolor="white",
        xaxis=dict(showgrid=False),
        yaxis=dict(gridcolor="rgba(200,200,200,0.5)"),
    )
    return fig

plot_metric_violin

plot_metric_violin(results: Dict[str, MetricResult], metric_name: str, dist_key: str = 'per_lead_all_distances', leads: Optional[List[str]] = None) -> Figure

Violin plot showing the distribution of pairwise distances per lead. Only meaningful for metrics that store per-pair distance lists in extra[dist_key].

Parameters:

Name Type Description Default
results dict mapping dataset_label → MetricResult
required
metric_name str
required
dist_key str — key in MetricResult.extra that holds {lead: [distances]}
'per_lead_all_distances'
leads list of str
None
Source code in ecgen_eval/visualization/metric_plots.py
def plot_metric_violin(
    results: Dict[str, MetricResult],
    metric_name: str,
    dist_key: str = "per_lead_all_distances",
    leads: Optional[List[str]] = None,
) -> go.Figure:
    """
    Violin plot showing the distribution of pairwise distances per lead.
    Only meaningful for metrics that store per-pair distance lists in extra[dist_key].

    Parameters
    ----------
    results : dict mapping dataset_label → MetricResult
    metric_name : str
    dist_key : str — key in MetricResult.extra that holds {lead: [distances]}
    leads : list of str, optional
    """
    # Check if data is available
    sample_result = next(iter(results.values()))
    if dist_key not in sample_result.extra:
        return _empty_figure(f"No per-pair distances available for {metric_name}")

    all_leads = list(next(iter(results.values())).extra[dist_key].keys())
    if leads:
        all_leads = [l for l in leads if l in all_leads]

    n_cols = min(4, len(all_leads))
    n_rows = (len(all_leads) + n_cols - 1) // n_cols

    fig = make_subplots(
        rows=n_rows,
        cols=n_cols,
        subplot_titles=all_leads,
        vertical_spacing=0.12,
        horizontal_spacing=0.06,
    )

    for li, lead in enumerate(all_leads):
        row = li // n_cols + 1
        col = li % n_cols + 1
        for si, (label, result) in enumerate(results.items()):
            dists = result.extra.get(dist_key, {}).get(lead, [])
            if not dists:
                continue
            fig.add_trace(
                go.Violin(
                    y=dists,
                    name=label,
                    legendgroup=label,
                    showlegend=(li == 0),
                    line_color=get_synth_color(si),
                    fillcolor=get_synth_color(si),
                    opacity=0.6,
                    box_visible=True,
                    meanline_visible=True,
                    points=False,
                ),
                row=row, col=col,
            )

    fig.update_layout(
        title=f"{metric_name} Pairwise Distance Distribution per Lead",
        height=280 * n_rows + 80,
        violinmode="group",
        plot_bgcolor="white",
        paper_bgcolor="white",
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    )
    fig.update_yaxes(gridcolor="rgba(200,200,200,0.5)", zeroline=False)
    return fig

plot_radar_chart

plot_radar_chart(scores_by_dataset: Dict[str, Dict[str, float]], metric_names: Optional[List[str]] = None, higher_is_better: Optional[Dict[str, bool]] = None) -> Figure

Radar (spider) chart with one polygon per synthetic dataset. Scores are normalised so that the outer edge = best.

Parameters:

Name Type Description Default
scores_by_dataset dataset_label: {metric_name: score}
dataset_label: {metric_name: score}
metric_names order of metrics on radar axes
None
higher_is_better metric_name: bool
metric_name: bool
Source code in ecgen_eval/visualization/metric_plots.py
def plot_radar_chart(
    scores_by_dataset: Dict[str, Dict[str, float]],
    metric_names: Optional[List[str]] = None,
    higher_is_better: Optional[Dict[str, bool]] = None,
) -> go.Figure:
    """
    Radar (spider) chart with one polygon per synthetic dataset.
    Scores are normalised so that the outer edge = best.

    Parameters
    ----------
    scores_by_dataset : {dataset_label: {metric_name: score}}
    metric_names : order of metrics on radar axes
    higher_is_better : {metric_name: bool}
    """
    if metric_names is None:
        metric_names = sorted(
            {m for s in scores_by_dataset.values() for m in s}
        )

    if higher_is_better is None:
        higher_is_better = {}

    # Normalise: map each metric to [0, 1] where 1 = best
    norm_scores: Dict[str, Dict[str, float]] = {ds: {} for ds in scores_by_dataset}

    for metric in metric_names:
        vals = {
            ds: scores_by_dataset[ds].get(metric, np.nan)
            for ds in scores_by_dataset
        }
        valid = [v for v in vals.values() if not np.isnan(v)]
        if not valid:
            continue
        lo, hi = min(valid), max(valid)
        span = (hi - lo) or 1.0

        hib = higher_is_better.get(metric, False)
        for ds, v in vals.items():
            if np.isnan(v):
                norm_scores[ds][metric] = 0.0
            elif hib:
                norm_scores[ds][metric] = (v - lo) / span
            else:
                norm_scores[ds][metric] = 1.0 - (v - lo) / span

    fig = go.Figure()
    for si, (label, scores) in enumerate(norm_scores.items()):
        r_vals = [scores.get(m, 0.0) for m in metric_names]
        r_vals_closed = r_vals + [r_vals[0]]
        theta_closed = metric_names + [metric_names[0]]
        fig.add_trace(
            go.Scatterpolar(
                r=r_vals_closed,
                theta=theta_closed,
                fill="toself",
                name=label,
                line_color=get_synth_color(si),
                fillcolor=get_synth_color(si),
                opacity=0.4,
            )
        )

    fig.update_layout(
        polar=dict(
            radialaxis=dict(visible=True, range=[0, 1], tickfont_size=9),
            angularaxis=dict(tickfont_size=11),
            bgcolor="white",
        ),
        showlegend=True,
        title="Metric Summary Radar (outer edge = best quality)",
        height=500,
        paper_bgcolor="white",
        legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5),
    )
    return fig

plot_psd_overlay

plot_psd_overlay(results: Dict[str, MetricResult], fs: float = 500.0, leads: Optional[List[str]] = None, max_freq_hz: float = 40.0) -> Figure

Overlay the mean normalised PSD of real vs. synthetic datasets per lead.

Only meaningful for PSDMetric results which store mean PSDs in result.extra["per_lead_psd_real"] and result.extra["per_lead_psd_synth"].

Parameters:

Name Type Description Default
results dict dataset_label → MetricResult
required
fs float sampling frequency (used to build frequency axis)
500.0
leads list of str
None
max_freq_hz float clip display to this frequency (default 40 Hz)
40.0
Source code in ecgen_eval/visualization/metric_plots.py
def plot_psd_overlay(
    results: Dict[str, MetricResult],
    fs: float = 500.0,
    leads: Optional[List[str]] = None,
    max_freq_hz: float = 40.0,
) -> go.Figure:
    """
    Overlay the mean normalised PSD of real vs. synthetic datasets per lead.

    Only meaningful for PSDMetric results which store mean PSDs in
    ``result.extra["per_lead_psd_real"]`` and ``result.extra["per_lead_psd_synth"]``.

    Parameters
    ----------
    results : dict  dataset_label → MetricResult
    fs : float      sampling frequency (used to build frequency axis)
    leads : list of str, optional
    max_freq_hz : float   clip display to this frequency (default 40 Hz)
    """
    sample_result = next(iter(results.values()))
    if "per_lead_psd_real" not in sample_result.extra:
        return _empty_figure("PSD data not available (run PSDMetric first)")

    all_leads = list(sample_result.extra["per_lead_psd_real"].keys())
    if leads:
        all_leads = [l for l in leads if l in all_leads]

    n_cols = min(4, len(all_leads))
    n_rows = (len(all_leads) + n_cols - 1) // n_cols

    fig = make_subplots(
        rows=n_rows, cols=n_cols,
        subplot_titles=all_leads,
        vertical_spacing=0.14,
        horizontal_spacing=0.06,
    )

    for li, lead in enumerate(all_leads):
        row = li // n_cols + 1
        col = li % n_cols + 1

        # Build frequency axis from PSD length
        psd_r_first = np.array(sample_result.extra["per_lead_psd_real"][lead])
        n_freqs = len(psd_r_first)
        freqs = np.linspace(0, fs / 2, n_freqs)
        freq_mask = freqs <= max_freq_hz

        # Real (use first result's real PSD — same for all synthetic comparisons)
        psd_real = np.array(sample_result.extra["per_lead_psd_real"][lead])
        fig.add_trace(
            go.Scatter(
                x=freqs[freq_mask].tolist(),
                y=psd_real[freq_mask].tolist(),
                name="Real",
                legendgroup="real",
                showlegend=(li == 0),
                line=dict(color="black", width=2),
                mode="lines",
            ),
            row=row, col=col,
        )

        for si, (label, result) in enumerate(results.items()):
            psd_synth = np.array(result.extra["per_lead_psd_synth"].get(lead, []))
            if len(psd_synth) == 0:
                continue
            fig.add_trace(
                go.Scatter(
                    x=freqs[freq_mask].tolist(),
                    y=psd_synth[freq_mask].tolist(),
                    name=label,
                    legendgroup=label,
                    showlegend=(li == 0),
                    line=dict(color=get_synth_color(si), width=1.5, dash="dash"),
                    mode="lines",
                    opacity=0.8,
                ),
                row=row, col=col,
            )

    fig.update_layout(
        title=f"Mean Normalised PSD per Lead (0–{max_freq_hz:.0f} Hz)",
        height=280 * n_rows + 80,
        plot_bgcolor="white",
        paper_bgcolor="white",
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    )
    fig.update_xaxes(title_text="Frequency (Hz)", gridcolor="rgba(200,200,200,0.5)")
    fig.update_yaxes(title_text="Normalised Power", gridcolor="rgba(200,200,200,0.5)")
    return fig

Clinical feature plots

2D and 3D distribution comparison plots for clinical ECG features.

All functions return self-contained Plotly Figure objects suitable for embedding in the HTML report.

plot_feature_2d

plot_feature_2d(real_df: DataFrame, synth_df: DataFrame, x_feature: str, y_feature: str, synth_label: str = 'Synthetic', real_label: str = 'Real', max_scatter_pts: int = 500) -> Figure

2D distribution comparison for one feature pair.

Shows KDE contours (Histogram2dContour) plus a subsampled scatter overlay.

Parameters:

Name Type Description Default
real_df pd.DataFrame — summary feature DataFrames.
required
synth_df pd.DataFrame — summary feature DataFrames.
required
x_feature str — column names.
required
y_feature str — column names.
required
synth_label str — legend labels.
'Synthetic'
real_label str — legend labels.
'Synthetic'
max_scatter_pts int — cap on scatter points to keep plot fast.
500

Returns:

Type Description
Figure
Source code in ecgen_eval/visualization/feature_plots.py
def plot_feature_2d(
    real_df: pd.DataFrame,
    synth_df: pd.DataFrame,
    x_feature: str,
    y_feature: str,
    synth_label: str = "Synthetic",
    real_label: str = "Real",
    max_scatter_pts: int = 500,
) -> go.Figure:
    """
    2D distribution comparison for one feature pair.

    Shows KDE contours (Histogram2dContour) plus a subsampled scatter overlay.

    Parameters
    ----------
    real_df, synth_df : pd.DataFrame  — summary feature DataFrames.
    x_feature, y_feature : str        — column names.
    synth_label, real_label : str     — legend labels.
    max_scatter_pts : int             — cap on scatter points to keep plot fast.

    Returns
    -------
    plotly.graph_objects.Figure
    """
    rx = _clean(real_df, [x_feature, y_feature])
    sx = _clean(synth_df, [x_feature, y_feature])

    fig = go.Figure()

    if not rx.empty:
        fig.add_trace(_kde_contour(rx[x_feature].values, rx[y_feature].values, "Blues", real_label))
        pts = rx.sample(min(len(rx), max_scatter_pts), random_state=0)
        fig.add_trace(go.Scatter(
            x=pts[x_feature], y=pts[y_feature],
            mode="markers",
            name=real_label,
            marker=dict(color=_REAL_COLOUR, size=5, line=dict(width=0.5, color=_REAL_LINE)),
            showlegend=True,
        ))

    if not sx.empty:
        fig.add_trace(_kde_contour(sx[x_feature].values, sx[y_feature].values, "Oranges", synth_label))
        pts = sx.sample(min(len(sx), max_scatter_pts), random_state=0)
        fig.add_trace(go.Scatter(
            x=pts[x_feature], y=pts[y_feature],
            mode="markers",
            name=synth_label,
            marker=dict(color=_SYNTH_COLOUR, size=5, line=dict(width=0.5, color=_SYNTH_LINE)),
            showlegend=True,
        ))

    fig.update_layout(
        title=f"{_pretty_label(x_feature)} vs {_pretty_label(y_feature)}",
        xaxis_title=_pretty_label(x_feature),
        yaxis_title=_pretty_label(y_feature),
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
        height=450,
        template="plotly_white",
    )
    return fig

plot_feature_pair_grid

plot_feature_pair_grid(real_df: DataFrame, synth_df: DataFrame, pairs: Optional[List[Tuple[str, str]]] = None, synth_label: str = 'Synthetic', real_label: str = 'Real') -> Figure

Grid of 2D distribution comparison subplots, one per feature pair.

Parameters:

Name Type Description Default
pairs list of (x_feature, y_feature) tuples.
Defaults to DEFAULT_2D_PAIRS filtered to available columns.
None

Returns:

Type Description
Figure
Source code in ecgen_eval/visualization/feature_plots.py
def plot_feature_pair_grid(
    real_df: pd.DataFrame,
    synth_df: pd.DataFrame,
    pairs: Optional[List[Tuple[str, str]]] = None,
    synth_label: str = "Synthetic",
    real_label: str = "Real",
) -> go.Figure:
    """
    Grid of 2D distribution comparison subplots, one per feature pair.

    Parameters
    ----------
    pairs : list of (x_feature, y_feature) tuples.
            Defaults to DEFAULT_2D_PAIRS filtered to available columns.

    Returns
    -------
    plotly.graph_objects.Figure
    """
    if pairs is None:
        pairs = DEFAULT_2D_PAIRS

    all_cols = set(real_df.columns) | set(synth_df.columns)
    pairs = [(x, y) for x, y in pairs if x in all_cols and y in all_cols]
    if not pairs:
        return go.Figure().update_layout(title="No valid feature pairs found")

    n = len(pairs)
    ncols = min(2, n)
    nrows = (n + ncols - 1) // ncols

    fig = make_subplots(
        rows=nrows, cols=ncols,
        subplot_titles=[f"{_pretty_label(x)} vs {_pretty_label(y)}" for x, y in pairs],
        horizontal_spacing=0.12,
        vertical_spacing=0.15,
    )

    show_legend = True  # only attach legend entries on first subplot
    for idx, (x_feat, y_feat) in enumerate(pairs):
        row = idx // ncols + 1
        col = idx % ncols + 1

        rx = _clean(real_df, [x_feat, y_feat])
        sx = _clean(synth_df, [x_feat, y_feat])

        if not rx.empty:
            pts = rx.sample(min(len(rx), 300), random_state=0)
            fig.add_trace(go.Scatter(
                x=pts[x_feat], y=pts[y_feat], mode="markers",
                name=real_label,
                marker=dict(color=_REAL_COLOUR, size=5),
                legendgroup="real", showlegend=show_legend,
            ), row=row, col=col)

        if not sx.empty:
            pts = sx.sample(min(len(sx), 300), random_state=0)
            fig.add_trace(go.Scatter(
                x=pts[x_feat], y=pts[y_feat], mode="markers",
                name=synth_label,
                marker=dict(color=_SYNTH_COLOUR, size=5),
                legendgroup="synth", showlegend=show_legend,
            ), row=row, col=col)

        show_legend = False

        fig.update_xaxes(title_text=_pretty_label(x_feat), row=row, col=col)
        fig.update_yaxes(title_text=_pretty_label(y_feat), row=row, col=col)

    fig.update_layout(
        height=400 * nrows,
        title_text="Clinical Feature Distributions (2D)",
        template="plotly_white",
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    )
    return fig

plot_feature_2d_multi

plot_feature_2d_multi(real_df: DataFrame, synth_dfs: List[DataFrame], x_feature: str, y_feature: str, synth_labels: Optional[List[str]] = None, real_label: str = 'Real', max_scatter_pts: int = 400) -> Figure

2D scatter for real + multiple synthetic DataFrames.

Source code in ecgen_eval/visualization/feature_plots.py
def plot_feature_2d_multi(
    real_df: pd.DataFrame,
    synth_dfs: List[pd.DataFrame],
    x_feature: str,
    y_feature: str,
    synth_labels: Optional[List[str]] = None,
    real_label: str = "Real",
    max_scatter_pts: int = 400,
) -> go.Figure:
    """2D scatter for real + multiple synthetic DataFrames."""
    if synth_labels is None:
        synth_labels = [f"Synthetic {i+1}" for i in range(len(synth_dfs))]

    fig = go.Figure()

    rx = _clean(real_df, [x_feature, y_feature])
    if not rx.empty:
        pts = rx.sample(min(len(rx), max_scatter_pts), random_state=0)
        fig.add_trace(go.Scatter(
            x=pts[x_feature], y=pts[y_feature], mode="markers",
            name=real_label, marker=dict(color=_REAL_COLOUR, size=6),
        ))

    for i, (sdf, slbl) in enumerate(zip(synth_dfs, synth_labels)):
        sx = _clean(sdf, [x_feature, y_feature])
        if sx.empty:
            continue
        pts = sx.sample(min(len(sx), max_scatter_pts), random_state=0)
        colour = _MULTI_COLOURS[i % len(_MULTI_COLOURS)]
        fig.add_trace(go.Scatter(
            x=pts[x_feature], y=pts[y_feature], mode="markers",
            name=slbl, marker=dict(color=colour, size=6),
        ))

    fig.update_layout(
        title=f"{_pretty_label(x_feature)} vs {_pretty_label(y_feature)}",
        xaxis_title=_pretty_label(x_feature),
        yaxis_title=_pretty_label(y_feature),
        height=450, template="plotly_white",
    )
    return fig

plot_feature_3d

plot_feature_3d(real_df: DataFrame, synth_df: DataFrame, x_feature: str, y_feature: str, z_feature: str, synth_label: str = 'Synthetic', real_label: str = 'Real', max_scatter_pts: int = 600) -> Figure

Interactive 3D scatter comparing real and synthetic feature distributions.

Parameters:

Name Type Description Default
real_df DataFrame
required
synth_df DataFrame
required
x_feature str — column names.
required
y_feature str — column names.
required
z_feature str — column names.
required

Returns:

Type Description
Figure(Scatter3d)
Source code in ecgen_eval/visualization/feature_plots.py
def plot_feature_3d(
    real_df: pd.DataFrame,
    synth_df: pd.DataFrame,
    x_feature: str,
    y_feature: str,
    z_feature: str,
    synth_label: str = "Synthetic",
    real_label: str = "Real",
    max_scatter_pts: int = 600,
) -> go.Figure:
    """
    Interactive 3D scatter comparing real and synthetic feature distributions.

    Parameters
    ----------
    real_df, synth_df : pd.DataFrame
    x_feature, y_feature, z_feature : str  — column names.

    Returns
    -------
    plotly.graph_objects.Figure  (Scatter3d)
    """
    cols = [x_feature, y_feature, z_feature]
    rx = _clean(real_df, cols)
    sx = _clean(synth_df, cols)

    fig = go.Figure()

    if not rx.empty:
        pts = rx.sample(min(len(rx), max_scatter_pts), random_state=0)
        fig.add_trace(go.Scatter3d(
            x=pts[x_feature], y=pts[y_feature], z=pts[z_feature],
            mode="markers",
            name=real_label,
            marker=dict(size=4, color=_REAL_COLOUR, opacity=0.75),
        ))

    if not sx.empty:
        pts = sx.sample(min(len(sx), max_scatter_pts), random_state=0)
        fig.add_trace(go.Scatter3d(
            x=pts[x_feature], y=pts[y_feature], z=pts[z_feature],
            mode="markers",
            name=synth_label,
            marker=dict(size=4, color=_SYNTH_COLOUR, opacity=0.75),
        ))

    fig.update_layout(
        scene=dict(
            xaxis_title=_pretty_label(x_feature),
            yaxis_title=_pretty_label(y_feature),
            zaxis_title=_pretty_label(z_feature),
        ),
        title=(
            f"{_pretty_label(x_feature)} / {_pretty_label(y_feature)} / "
            f"{_pretty_label(z_feature)}"
        ),
        height=550,
        template="plotly_white",
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    )
    return fig

plot_feature_triple_grid

plot_feature_triple_grid(real_df: DataFrame, synth_df: DataFrame, triples: Optional[List[Tuple[str, str, str]]] = None, synth_label: str = 'Synthetic', real_label: str = 'Real') -> Figure

Grid of 3D scatter subplots, one per feature triple.

Because Plotly does not support 3D scene subplots via make_subplots as intuitively as 2D, we stack scenes (scene, scene2, scene3, …) manually.

Returns:

Type Description
Figure
Source code in ecgen_eval/visualization/feature_plots.py
def plot_feature_triple_grid(
    real_df: pd.DataFrame,
    synth_df: pd.DataFrame,
    triples: Optional[List[Tuple[str, str, str]]] = None,
    synth_label: str = "Synthetic",
    real_label: str = "Real",
) -> go.Figure:
    """
    Grid of 3D scatter subplots, one per feature triple.

    Because Plotly does not support 3D scene subplots via make_subplots as
    intuitively as 2D, we stack scenes (scene, scene2, scene3, …) manually.

    Returns
    -------
    plotly.graph_objects.Figure
    """
    if triples is None:
        triples = DEFAULT_3D_TRIPLES

    all_cols = set(real_df.columns) | set(synth_df.columns)
    triples = [(x, y, z) for x, y, z in triples if x in all_cols and y in all_cols and z in all_cols]
    if not triples:
        return go.Figure().update_layout(title="No valid feature triples found")

    n = len(triples)
    ncols = min(2, n)
    nrows = (n + ncols - 1) // ncols

    # Build scenes dict for layout
    scenes = {}
    scene_refs = []
    for i in range(n):
        key = "scene" if i == 0 else f"scene{i+1}"
        row = i // ncols
        col = i % ncols
        scenes[key] = dict(
            xaxis_title=_pretty_label(triples[i][0]),
            yaxis_title=_pretty_label(triples[i][1]),
            zaxis_title=_pretty_label(triples[i][2]),
            domain=dict(
                x=[col / ncols + 0.02, (col + 1) / ncols - 0.02],
                y=[1.0 - (row + 1) / nrows + 0.02, 1.0 - row / nrows - 0.02],
            ),
        )
        scene_refs.append(key)

    fig = go.Figure()
    show_legend = True
    for i, (x_feat, y_feat, z_feat) in enumerate(triples):
        cols = [x_feat, y_feat, z_feat]
        rx = _clean(real_df, cols)
        sx = _clean(synth_df, cols)
        scene_key = scene_refs[i]

        if not rx.empty:
            pts = rx.sample(min(len(rx), 300), random_state=0)
            fig.add_trace(go.Scatter3d(
                x=pts[x_feat], y=pts[y_feat], z=pts[z_feat],
                mode="markers", name=real_label,
                marker=dict(size=3, color=_REAL_COLOUR, opacity=0.75),
                scene=scene_key,
                legendgroup="real", showlegend=show_legend,
            ))

        if not sx.empty:
            pts = sx.sample(min(len(sx), 300), random_state=0)
            fig.add_trace(go.Scatter3d(
                x=pts[x_feat], y=pts[y_feat], z=pts[z_feat],
                mode="markers", name=synth_label,
                marker=dict(size=3, color=_SYNTH_COLOUR, opacity=0.75),
                scene=scene_key,
                legendgroup="synth", showlegend=show_legend,
            ))

        show_legend = False

    layout_update = dict(
        title_text="Clinical Feature Distributions (3D)",
        height=max(500, 500 * nrows),
        template="plotly_white",
        **scenes,
    )
    fig.update_layout(**layout_update)
    return fig