Skip to content

Plotting

sunburst

sunburst

sunburst(graph: ConceptGraph, df: DataFrame, *, value: str = 'count', title: str | None = None, colorscale: str | None = None, color_value: str | None = None, branchvalues: str = 'total', extra_hover: list[str] | None = None, hover_fmt: dict[str, str] | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst from a ConceptGraph + a metric DataFrame.

PARAMETER DESCRIPTION
graph

The ConceptGraph to render.

TYPE: ConceptGraph

df

Tidy DataFrame produced by one of the metric functions. Must be indexed by path and contain value (and color_value if coloring is requested).

TYPE: DataFrame

value

Column used for sector size. Defaults to "count".

TYPE: str DEFAULT: 'count'

title

Figure title.

TYPE: str | None DEFAULT: None

colorscale

Plotly colorscale name (e.g. "Viridis", "Reds"). When set, sectors are colored by color_value (which defaults to value).

TYPE: str | None DEFAULT: None

color_value

Column used for color intensity. Defaults to value when colorscale is set.

TYPE: str | None DEFAULT: None

branchvalues

Plotly sunburst branchvalues ("total" or "remainder").

TYPE: str DEFAULT: 'total'

extra_hover

Additional columns to append to the hover tooltip.

TYPE: list[str] | None DEFAULT: None

hover_fmt

Per-column format spec strings (e.g. {"importance_sum": ".4f"}).

TYPE: dict[str, str] | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/sunburst.py
def sunburst(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "count",
    title: str | None = None,
    colorscale: str | None = None,
    color_value: str | None = None,
    branchvalues: str = "total",
    extra_hover: list[str] | None = None,
    hover_fmt: dict[str, str] | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst from a ConceptGraph + a metric DataFrame.

    Parameters
    ----------
    graph:
        The ConceptGraph to render.
    df:
        Tidy DataFrame produced by one of the metric functions. Must be
        indexed by ``path`` and contain ``value`` (and ``color_value`` if
        coloring is requested).
    value:
        Column used for sector size. Defaults to ``"count"``.
    title:
        Figure title.
    colorscale:
        Plotly colorscale name (e.g. ``"Viridis"``, ``"Reds"``). When set,
        sectors are colored by ``color_value`` (which defaults to ``value``).
    color_value:
        Column used for color intensity. Defaults to ``value`` when
        ``colorscale`` is set.
    branchvalues:
        Plotly sunburst branchvalues (``"total"`` or ``"remainder"``).
    extra_hover:
        Additional columns to append to the hover tooltip.
    hover_fmt:
        Per-column ``format`` spec strings (e.g. ``{"importance_sum": ".4f"}``).
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    arrays = graph_to_arrays(graph)
    ordered = reindex_to_paths(df, arrays["ids"])
    if value not in ordered.columns:
        raise KeyError(f"value column {value!r} not in DataFrame; have {list(ordered.columns)}")

    values = ordered[value].fillna(0).to_numpy(dtype=float)

    marker: dict[str, Any] = {"line": {"width": 0.5, "color": "white"}}
    if colorscale is not None:
        cv = color_value or value
        if cv not in ordered.columns:
            raise KeyError(f"color_value column {cv!r} not in DataFrame")
        marker.update(
            colors=ordered[cv].fillna(0).to_numpy(dtype=float),
            colorscale=colorscale,
            showscale=True,
            cmid=0 if (ordered[cv].min() < 0 < ordered[cv].max()) else None,
            colorbar={"title": cv},
        )

    hover_columns = [value]
    for col in ("kind", "feature_count", "used_feature_count", "is_used"):
        if col in ordered.columns and col not in hover_columns:
            hover_columns.append(col)
    if extra_hover:
        for col in extra_hover:
            if col not in hover_columns:
                hover_columns.append(col)

    hover = hover_text(ordered, hover_columns, fmt=hover_fmt)

    fig = go.Figure(
        go.Sunburst(
            ids=arrays["ids"],
            labels=arrays["labels"],
            parents=arrays["parents"],
            values=values,
            branchvalues=branchvalues,
            marker=marker,
            hovertext=hover,
            hovertemplate="<b>%{label}</b><br>%{hovertext}<extra></extra>",
            insidetextorientation="radial",
        )
    )
    fig.update_layout(
        title=title,
        margin={"t": 40, "l": 0, "r": 0, "b": 0},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

utilization_map

utilization_map

utilization_map(graph: ConceptGraph, df: DataFrame, *, value: str = 'feature_count', used_color: str = '#1f77b4', unused_color: str = '#d3d3d3', title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst where unused branches are grey.

The DataFrame must be the output of :func:concept_graph_xai.metrics.utilization (it requires the is_used column).

Source code in src/concept_graph_xai/plotting/utilization_map.py
def utilization_map(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "feature_count",
    used_color: str = "#1f77b4",
    unused_color: str = "#d3d3d3",
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst where unused branches are grey.

    The DataFrame must be the output of
    :func:`concept_graph_xai.metrics.utilization` (it requires the ``is_used``
    column).
    """

    if "is_used" not in df.columns:
        raise KeyError("utilization_map expects DataFrame from metrics.utilization (no is_used col)")

    arrays = graph_to_arrays(graph)
    ordered = reindex_to_paths(df, arrays["ids"])

    if value not in ordered.columns:
        raise KeyError(f"value column {value!r} not in DataFrame")
    values = ordered[value].fillna(0).to_numpy(dtype=float)

    colors = [used_color if bool(u) else unused_color for u in ordered["is_used"].to_numpy()]

    hover_cols = [value, "is_used", "used_feature_count", "feature_count", "importance_sum"]
    hover_cols = [c for c in hover_cols if c in ordered.columns]
    hover = hover_text(
        ordered,
        hover_cols,
        fmt={"importance_sum": ".4f"},
    )

    fig = go.Figure(
        go.Sunburst(
            ids=arrays["ids"],
            labels=arrays["labels"],
            parents=arrays["parents"],
            values=values,
            branchvalues="total",
            marker={"colors": colors, "line": {"width": 0.5, "color": "white"}},
            hovertext=hover,
            hovertemplate="<b>%{label}</b><br>%{hovertext}<extra></extra>",
            insidetextorientation="radial",
        )
    )
    fig.update_layout(
        title=title or "Concept utilization (grey = unused)",
        margin={"t": 40, "l": 0, "r": 0, "b": 0},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

auc_drop_map

auc_drop_map

auc_drop_map(graph: ConceptGraph, df: DataFrame, *, value: str = 'auc_drop_mean', size: str = 'feature_count', colorscale: str = 'Reds', title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst where each concept is colored by its AUC drop.

Sector area uses size (feature count by default), the colour intensity uses value (mean AUC drop by default).

Source code in src/concept_graph_xai/plotting/auc_drop_map.py
def auc_drop_map(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "auc_drop_mean",
    size: str = "feature_count",
    colorscale: str = "Reds",
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst where each concept is colored by its AUC drop.

    Sector area uses ``size`` (feature count by default), the colour intensity
    uses ``value`` (mean AUC drop by default).
    """

    if value not in df.columns:
        raise KeyError(f"{value!r} not in DataFrame; run metrics.auc_drop first")
    if size not in df.columns:
        raise KeyError(f"{size!r} not in DataFrame")

    arrays = graph_to_arrays(graph)
    ordered = reindex_to_paths(df, arrays["ids"])

    sizes = ordered[size].fillna(0).to_numpy(dtype=float)
    drop_vals = ordered[value].to_numpy(dtype=float)
    drop_for_color = np.where(np.isnan(drop_vals), 0.0, drop_vals)

    cmax = float(np.nanmax(np.abs(drop_vals))) if not np.all(np.isnan(drop_vals)) else 1.0
    cmin = -cmax if (np.nanmin(drop_vals) < 0) else 0.0

    hover_cols = [
        value,
        "auc_drop_std",
        "ablated_score_mean",
        "baseline_score",
        "feature_count",
        "strategy",
    ]
    hover_cols = [c for c in hover_cols if c in ordered.columns]
    hover = hover_text(
        ordered,
        hover_cols,
        fmt={
            value: "+.4f",
            "auc_drop_std": ".4f",
            "ablated_score_mean": ".4f",
            "baseline_score": ".4f",
        },
    )

    fig = go.Figure(
        go.Sunburst(
            ids=arrays["ids"],
            labels=arrays["labels"],
            parents=arrays["parents"],
            values=sizes,
            branchvalues="total",
            marker={
                "colors": drop_for_color,
                "colorscale": colorscale,
                "cmin": cmin,
                "cmax": cmax,
                "showscale": True,
                "colorbar": {"title": value},
                "line": {"width": 0.5, "color": "white"},
            },
            hovertext=hover,
            hovertemplate="<b>%{label}</b><br>%{hovertext}<extra></extra>",
            insidetextorientation="radial",
        )
    )
    fig.update_layout(
        title=title or "AUC drop per concept",
        margin={"t": 40, "l": 0, "r": 0, "b": 0},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

correlation_block

correlation_block

correlation_block(result: CorrelationResult, *, title: str | None = None, show_block_labels: bool = True, annotate_mean_abs: bool = True, colorscale: str = 'RdBu', zmid: float = 0.0, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a correlation matrix with concept-block separators.

Works on the output of any of :func:feature_correlation, :func:nullity_correlation, or :func:shap_correlation — they all return a :class:CorrelationResult.

PARAMETER DESCRIPTION
result

Output of one of the correlation metrics.

TYPE: CorrelationResult

title

Figure title.

TYPE: str | None DEFAULT: None

show_block_labels

Draw the concept name above each diagonal block.

TYPE: bool DEFAULT: True

annotate_mean_abs

Print mean(|r|) inside each diagonal block.

TYPE: bool DEFAULT: True

colorscale

Plotly colorscale name. Default RdBu is symmetric around zero.

TYPE: str DEFAULT: 'RdBu'

zmid

Mid value for the colorscale. Use 0 for a diverging palette.

TYPE: float DEFAULT: 0.0

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/correlation_block.py
def correlation_block(
    result: CorrelationResult,
    *,
    title: str | None = None,
    show_block_labels: bool = True,
    annotate_mean_abs: bool = True,
    colorscale: str = "RdBu",
    zmid: float = 0.0,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a correlation matrix with concept-block separators.

    Works on the output of any of :func:`feature_correlation`,
    :func:`nullity_correlation`, or :func:`shap_correlation` — they all return
    a :class:`CorrelationResult`.

    Parameters
    ----------
    result:
        Output of one of the correlation metrics.
    title:
        Figure title.
    show_block_labels:
        Draw the concept name above each diagonal block.
    annotate_mean_abs:
        Print ``mean(|r|)`` inside each diagonal block.
    colorscale:
        Plotly colorscale name. Default ``RdBu`` is symmetric around zero.
    zmid:
        Mid value for the colorscale. Use ``0`` for a diverging palette.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    matrix = result.matrix
    n = matrix.shape[0]

    fig = go.Figure(
        go.Heatmap(
            z=matrix.to_numpy(),
            x=list(matrix.columns),
            y=list(matrix.index),
            colorscale=colorscale,
            zmid=zmid,
            zmin=-1.0,
            zmax=1.0,
            colorbar={"title": f"{result.method} ρ"},
            hovertemplate="%{x} ↔ %{y}<br>%{z:.3f}<extra></extra>",
        )
    )

    shapes: list[dict[str, Any]] = []
    annotations: list[dict[str, Any]] = []
    stats_lookup = result.block_stats.set_index("concept_path").to_dict("index")

    for path, start, end in result.blocks:
        # Diagonal block border
        shapes.append(
            {
                "type": "rect",
                "xref": "x",
                "yref": "y",
                "x0": start - 0.5,
                "x1": end - 0.5,
                "y0": start - 0.5,
                "y1": end - 0.5,
                "line": {"color": "black", "width": 1.5},
                "fillcolor": "rgba(0,0,0,0)",
            }
        )
        if show_block_labels and end - start >= 1:
            label = path.split("/")[-1]
            annotations.append(
                {
                    "x": (start + end - 1) / 2,
                    "y": -1.2,
                    "xref": "x",
                    "yref": "y",
                    "text": f"<b>{label}</b>",
                    "showarrow": False,
                    "font": {"size": 11},
                }
            )
        if annotate_mean_abs and end - start >= 2:
            stats = stats_lookup.get(path, {})
            mean_abs = stats.get("mean_abs")
            if mean_abs is not None:
                annotations.append(
                    {
                        "x": (start + end - 1) / 2,
                        "y": (start + end - 1) / 2,
                        "xref": "x",
                        "yref": "y",
                        "text": f"|ρ̄|={mean_abs:.2f}",
                        "showarrow": False,
                        "font": {"size": 10, "color": "black"},
                        "bgcolor": "rgba(255,255,255,0.6)",
                    }
                )

    fig.update_layout(
        title=title,
        xaxis={"side": "bottom", "tickangle": 45, "showgrid": False, "range": [-0.5, n - 0.5]},
        yaxis={"autorange": "reversed", "showgrid": False, "range": [n - 0.5, -1.5]},
        shapes=shapes,
        annotations=annotations,
        margin={"t": 40, "l": 40, "r": 40, "b": 80},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

joint_missing_map

joint_missing_map

joint_missing_map(graph: ConceptGraph, df: DataFrame, *, value: str = 'joint_missing_rate', size: str = 'feature_count', colorscale: str = 'Reds', title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst where each concept is coloured by its joint-missing rate.

The DataFrame must come from :func:joint_missing_rate. Sector size uses feature_count so the shape matches the existing sunburst plots; colour intensity uses joint_missing_rate.

Source code in src/concept_graph_xai/plotting/joint_missing_map.py
def joint_missing_map(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "joint_missing_rate",
    size: str = "feature_count",
    colorscale: str = "Reds",
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst where each concept is coloured by its joint-missing rate.

    The DataFrame must come from :func:`joint_missing_rate`. Sector size uses
    ``feature_count`` so the shape matches the existing sunburst plots; colour
    intensity uses ``joint_missing_rate``.
    """

    if value not in df.columns:
        raise KeyError(f"{value!r} not in DataFrame; run joint_missing_rate first")
    if size not in df.columns:
        raise KeyError(f"{size!r} not in DataFrame")

    arrays = graph_to_arrays(graph)
    ordered = reindex_to_paths(df, arrays["ids"])

    sizes = ordered[size].fillna(0).to_numpy(dtype=float)
    rates = ordered[value].fillna(0).to_numpy(dtype=float)

    hover_cols = [value, "feature_count"]
    hover = hover_text(
        ordered,
        [c for c in hover_cols if c in ordered.columns],
        fmt={value: ".3f"},
    )

    fig = go.Figure(
        go.Sunburst(
            ids=arrays["ids"],
            labels=arrays["labels"],
            parents=arrays["parents"],
            values=sizes,
            branchvalues="total",
            marker={
                "colors": rates,
                "colorscale": colorscale,
                "cmin": 0.0,
                "cmax": max(1e-6, float(rates.max())),
                "showscale": True,
                "colorbar": {"title": value},
                "line": {"width": 0.5, "color": "white"},
            },
            hovertext=hover,
            hovertemplate="<b>%{label}</b><br>%{hovertext}<extra></extra>",
            insidetextorientation="radial",
        )
    )
    fig.update_layout(
        title=title or "Joint missingness per concept",
        margin={"t": 40, "l": 0, "r": 0, "b": 0},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

coherence_importance_scatter

coherence_importance_scatter

coherence_importance_scatter(df: DataFrame, *, only_concepts: bool = True, label_points: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render the coherence × importance quadrant scatter.

PARAMETER DESCRIPTION
df

Output of :func:coherence_importance. Must carry coherence, importance_sum and quadrant columns. Threshold values are read from df.attrs["coherence_threshold"] and df.attrs["importance_threshold"].

TYPE: DataFrame

only_concepts

Drop rows where kind == "feature" so the chart shows only business concepts.

TYPE: bool DEFAULT: True

label_points

Annotate every point with the concept name.

TYPE: bool DEFAULT: True

Source code in src/concept_graph_xai/plotting/coherence_importance_scatter.py
def coherence_importance_scatter(
    df: pd.DataFrame,
    *,
    only_concepts: bool = True,
    label_points: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render the coherence × importance quadrant scatter.

    Parameters
    ----------
    df:
        Output of :func:`coherence_importance`. Must carry ``coherence``,
        ``importance_sum`` and ``quadrant`` columns. Threshold values are
        read from ``df.attrs["coherence_threshold"]`` and
        ``df.attrs["importance_threshold"]``.
    only_concepts:
        Drop rows where ``kind == "feature"`` so the chart shows only
        business concepts.
    label_points:
        Annotate every point with the concept name.
    """

    needed = {"coherence", "importance_sum", "quadrant"}
    missing = needed - set(df.columns)
    if missing:
        raise KeyError(f"missing columns from coherence_importance: {missing}")

    plot_df = df.copy()
    if only_concepts and "kind" in plot_df.columns:
        plot_df = plot_df.loc[plot_df["kind"] == "concept"].copy()

    coh_thr = float(plot_df.attrs.get("coherence_threshold", df.attrs.get("coherence_threshold", 0.0)))
    imp_thr = float(plot_df.attrs.get("importance_threshold", df.attrs.get("importance_threshold", 0.0)))

    fig = go.Figure()
    for quadrant, color in _QUADRANT_COLOR.items():
        sub = plot_df.loc[plot_df["quadrant"] == quadrant]
        if sub.empty:
            continue
        fig.add_trace(
            go.Scatter(
                x=sub["coherence"],
                y=sub["importance_sum"],
                mode="markers+text" if label_points else "markers",
                text=sub["name"] if label_points else None,
                textposition="top center",
                marker={
                    "size": 12,
                    "color": color,
                    "line": {"color": "black", "width": 0.5},
                },
                name=quadrant.replace("_", " "),
                customdata=np.stack(
                    [
                        sub.get("feature_count", pd.Series([0] * len(sub))).to_numpy(),
                        sub.get("kind", pd.Series([""] * len(sub))).to_numpy(),
                    ],
                    axis=1,
                ),
                hovertemplate=(
                    "<b>%{text}</b><br>"
                    "coherence: %{x:.3f}<br>"
                    "importance: %{y:.4f}<br>"
                    "feature_count: %{customdata[0]}<br>"
                    "kind: %{customdata[1]}"
                    "<extra></extra>"
                ),
            )
        )

    fig.add_hline(y=imp_thr, line={"color": "black", "dash": "dash", "width": 1})
    fig.add_vline(x=coh_thr, line={"color": "black", "dash": "dash", "width": 1})

    fig.update_layout(
        title=title or "Concept coherence vs importance",
        xaxis_title=f"within-concept mean(|ρ|)  ({df.attrs.get('method', 'spearman')})",
        yaxis_title="summed |SHAP|",
        margin={"t": 60, "l": 60, "r": 30, "b": 60},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

regulatory_tag_overlay

regulatory_tag_overlay

regulatory_tag_overlay(graph: ConceptGraph, df: DataFrame | None = None, *, tag_key: str = 'tag', palette: dict[str, str] | None = None, untagged_color: str = '#dddddd', value: str = 'count', title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst whose sectors are coloured by a node-metadata tag.

PARAMETER DESCRIPTION
graph

ConceptGraph; tag is read from graph.view(node).metadata[tag_key].

TYPE: ConceptGraph

df

Optional DataFrame providing the feature_count column (or any value column). Defaults to a count-based sunburst.

TYPE: DataFrame | None DEFAULT: None

tag_key

Metadata key carrying the categorical tag.

TYPE: str DEFAULT: 'tag'

palette

Optional tag -> css_color mapping. Unmapped tags get colours from a default palette.

TYPE: dict[str, str] | None DEFAULT: None

untagged_color

Colour for nodes that carry no value under tag_key.

TYPE: str DEFAULT: '#dddddd'

Source code in src/concept_graph_xai/plotting/regulatory_tag_overlay.py
def regulatory_tag_overlay(
    graph: ConceptGraph,
    df: pd.DataFrame | None = None,
    *,
    tag_key: str = "tag",
    palette: dict[str, str] | None = None,
    untagged_color: str = "#dddddd",
    value: str = "count",
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst whose sectors are coloured by a node-metadata tag.

    Parameters
    ----------
    graph:
        ConceptGraph; tag is read from ``graph.view(node).metadata[tag_key]``.
    df:
        Optional DataFrame providing the ``feature_count`` column (or any
        ``value`` column). Defaults to a count-based sunburst.
    tag_key:
        Metadata key carrying the categorical tag.
    palette:
        Optional ``tag -> css_color`` mapping. Unmapped tags get colours from
        a default palette.
    untagged_color:
        Colour for nodes that carry no value under ``tag_key``.
    """

    arrays = graph_to_arrays(graph)
    if df is None:
        from concept_graph_xai.metrics.counts import feature_counts

        df = feature_counts(graph)

    ordered = reindex_to_paths(df, arrays["ids"])
    if value not in ordered.columns:
        raise KeyError(f"{value!r} column missing from DataFrame")
    sizes = ordered[value].fillna(0).to_numpy(dtype=float)

    tags: list[str] = []
    for node in graph.nodes_in_order():
        meta = graph.view(node).metadata
        tag = meta.get(tag_key)
        tags.append(str(tag) if tag is not None else "")

    palette_map = dict(palette) if palette else {}
    next_idx = 0
    for tag in tags:
        if tag and tag not in palette_map:
            palette_map[tag] = _DEFAULT_PALETTE[next_idx % len(_DEFAULT_PALETTE)]
            next_idx += 1

    colors = [palette_map.get(tag, untagged_color) for tag in tags]

    hover = hover_text(ordered.assign(tag=tags), [value, "tag"])

    fig = go.Figure(
        go.Sunburst(
            ids=arrays["ids"],
            labels=arrays["labels"],
            parents=arrays["parents"],
            values=sizes,
            branchvalues="total",
            marker={"colors": colors, "line": {"width": 0.5, "color": "white"}},
            hovertext=hover,
            hovertemplate="<b>%{label}</b><br>%{hovertext}<extra></extra>",
            insidetextorientation="radial",
        )
    )

    fig.update_layout(
        title=title or f"Concepts coloured by {tag_key!r}",
        margin={"t": 40, "l": 0, "r": 0, "b": 0},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig