Skip to content

Dense-plot label overlap gate + global auto_font option: auto-optimize plot fonts package-wide (+ auto-size feature_map) #219

Description

@breimanntools

Problem

Figures with many rows render overlapping text. The clearest offender is
CPPPlot.feature_map: its left-hand scale subcategory row labels collide as
soon as the figure has more than a handful of rows. At the full AAontology
breadth (74 subcategories) the label column becomes an unreadable black blur;
the overlap is already measurable at ~20 rows. There is no test that catches
this, so regressions ship silently and existing committed figures likely already
carry mild overlap.

Investigation notes (verified by rendering real figures, 2026-06-14):

  • The visible row labels are hand-placed ax.text artists, not real y-tick
    labels. Path: feature_map → plot_feature_map → PlotElements.add_subcat_bars → ut.plot_add_bars → _add_text_labels (aaanalysis/_utils/utils_plot_elements.py).
    They render at the current rcParams font size (~10pt); a vestigial 1pt
    set_yticklabels copy also exists (ignore it).
  • feature_map is a 5-axis composite (heatmap + top importance bars +
    colorbar + cumulative-importance + legend). A naive "any text overlaps any
    text" detector is unusable — it flags legitimate layered text. The test
    must scope to the row-label artists.
  • Reliable bbox measurement requires a forced render first
    (fig.savefig(BytesIO()) or fig.draw_without_rendering()); a bare
    get_window_extent returns degenerate ~1px heights and reports false "0 overlaps".
  • Separate bug found: the top importance-bar numeric ticks ('40' over '0')
    overlap independently of row count — fix in the same PR or split out.

Goal

  1. A test gate that renders dense figures and fails on overlapping
    row labels (scoped, low-false-positive).
  2. feature_map (and the other dense plots — heatmap, ranking, profile)
    self-adjust so labels never overlap and never drop below a legibility
    floor: shrink the label font to a floor (~5–6pt), then grow the figure
    height
    when even the floor would collide. Per maintainer steer, route the
    font adjustment through plot_settings (it scales fonts properly and has
    other benefits) rather than a bespoke per-call knob.

Requirements

  • Add a reusable test helper (e.g. tests/unit/plotting_tests/_text_overlap.py)
    that, given a fig and a set of target label strings, force-renders and
    returns label-vs-label bbox overlaps above a fraction threshold. Reference
    implementation below.
  • Add a fixture that builds a valid high-row df_feat from
    CPPPlot()._df_cat (one scale per subcategory → up to 74 rows). Reference below.
  • Add tests asserting feature_map has 0 row-label overlaps at 20 / 36 /
    55 / 74 subcategories.
  • Implement shrink-to-floor-then-grow, routed through plot_settings/the
    agreed sizing trigger. Decide the trigger (figsize default None=auto vs
    auto-fit within (7,5) vs a flag) — CONFIRM-FIRST if it changes the
    public feature_map signature/default.
  • Resolve the separate top importance-bar numeric tick overlap.
  • Re-run + re-commit affected example notebooks + the cheat-sheet figure
    (their committed outputs will change — that is intended).
  • Extend the gate to heatmap, ranking, profile.

KPIs / Acceptance criteria (measurable)

  • feature_map row-label-vs-row-label overlaps (≥30% of the smaller bbox area)
    = 0 at all of {20, 36, 55, 74} subcategories. (Today: 19 / 69 / 159 / 286.)
  • Rendered row-label font is never < 5pt.
  • Top importance-bar numeric ticks: 0 mutual overlaps.
  • Test runs headless (Agg) in < a few seconds; green on Linux + Windows.
  • No example/cheat-sheet figure regresses to more overlap than before.

Reference: validated detector

def get_label_overlaps(fig, names, min_frac=0.30):
    """Label-vs-label bbox overlaps. Forced render -> reliable window extents."""
    import io
    from matplotlib.transforms import Bbox
    fig.savefig(io.BytesIO(), format="png", dpi=fig.dpi)
    r = fig.canvas.get_renderer()
    nameset = set(names)
    items = [(t.get_text().strip(), t.get_window_extent(renderer=r))
             for ax in fig.axes for t in ax.texts
             if t.get_visible() and t.get_text().strip() in nameset]
    bad = []
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            it = Bbox.intersection(items[i][1], items[j][1])
            if it and it.width > 0 and it.height > 0:
                f = it.width*it.height / min(items[i][1].width*items[i][1].height,
                                             items[j][1].width*items[j][1].height)
                if f >= min_frac:
                    bad.append((items[i][0], items[j][0], round(f, 2)))
    return bad

Reference: high-row fixture

def make_dense_df_feat(n_subcat=74):
    import pandas as pd, aaanalysis as aa
    dc = aa.CPPPlot()._df_cat
    subs = list(dict.fromkeys(dc["subcategory"]))[:n_subcat]
    rows = []
    for s in subs:
        r = dc[dc["subcategory"] == s].iloc[0]
        rows.append(dict(feature=f"TMD_C_JMD_C-Segment(3,4)-{r['scale_id']}",
            category=r["category"], subcategory=s, scale_name=r["scale_name"],
            scale_description=r["scale_description"], abs_auc=0.2, abs_mean_dif=0.3,
            mean_dif=0.3, std_test=0.1, std_ref=0.1, p_val_mann_whitney=0.01,
            p_val_fdr_bh=0.02, positions="31,32,33,34,35", feat_importance=1.0,
            feat_importance_std=0.1))
    return pd.DataFrame(rows)

Scope / non-goals

  • Dense offenders first (feature_map tracer → heatmap/ranking/profile);
    sparse plots (eval, single feature, logos) later if needed.
  • Not a blanket "no two text artists overlap anywhere" gate — scoped to label
    artists to avoid false positives on legitimately layered composite axes.

Standards checklist

  • Library plotting code never calls plt.show() / plt.tight_layout();
    colors via ut.COLOR_*; plot_settings only from user-facing entry points.
  • tests headless (Agg, already set in tests/conftest.py); no print();
    bare ValueError/RuntimeError.
  • CONFIRM-FIRST if the feature_map public default/signature changes.

Sub-task B — Global auto_font mechanism: auto-optimize fonts for all plots (+ auto-size for feature_map)

(merged & broadened from #270; Sub-task A above is the reactive overlap guard + test gate for the dense composites. Both touch _cpp_plot.py, _backend/cpp/, _utils/utils_plot_elements.py — develop together / serialized.)

Problem (B)

Font sizes across the package are fixed absolutes set per method (fontsize_titles,
fontsize_labels, fontsize_annotations, fontsize_imp_bar, xtick_size,
seq_size/fontsize_tmd_jmd, …) plus the rcParams from aa.plot_settings(). They are
hand-tuned for one figure shape, so the same text is drawn whether a panel is small or
large — text looks too big on dense panels and too small on sparse ones. feature_map
is the worst case because its grid also changes shape with the data
(n_subcat rows × n_positions residue columns), which is why
tutorial3d_data_representations.ipynb renders with mismatched fonts. There is no single
switch that makes plots size their type to the panel.

Goal (B)

Add a global auto_font option (aa.options["auto_font"], in config.py) that, when
enabled, automatically optimizes font sizes for every plot in the package from the
rendered axes/figure size. For feature_map (and the dense composites) auto_font also
auto-derives the figure size from n_subcat × n_positions. Off by default (or default to
current behavior) → output byte-identical to today; explicit per-call figsize/fontsize_*
always override.

Requirements (B)

  • Global option auto_font in aa.options / aaanalysis/config.py (CONFIRM-FIRST:
    config.py is on the options surface). Thread it through the shared plotting glue so it
    applies package-wide, not per method.
  • General font auto-optimization (all *Plot methods): when auto_font is on and a
    fontsize_* arg is left at its sentinel (None/auto), derive that font from the rendered
    figure/axes size via one shared helper (e.g. in _utils/utils_plot_elements.py), with a
    legibility floor (reuse the ~5–6 pt floor from Sub-task A) and an upper clamp at the
    current fixed default.
  • feature_map/dense-plot size auto-scaling (the "+ size, if enabled" part): when
    auto_font is on, also map (n_subcat, n_positions) → recommended figsize via a fixed
    per-cell target clamped to min/max bounds, so a cell stays ~constant on-screen as the grid
    grows. Fold in Sub-task A's shrink-to-floor-then-grow.
  • Override precedence: explicit per-call figsize/fontsize_* > auto_font global >
    fixed defaults. numpydoc: document auto_font and that None font/size args mean "auto".
  • Re-run + re-commit tutorial3d_data_representations.ipynb + affected examples/* notebooks.

KPIs / Acceptance criteria (B, measurable)

  • With auto_font off (default), every plot is byte-identical to the current release
    (regression-tested) — no silent change for existing users.
  • With auto_font on, across n_subcat ∈ {5, 20, 55, 74} and n_positions ∈ {20, 40, 80}:
    feature_map on-screen cell size (figure inches / grid count) stays within ±15%.
  • With auto_font on, every auto-derived font stays in band: never < 5 pt, never larger
    than the current fixed default at the canonical shape — verified for ≥3 different *Plot
    methods (e.g. feature_map, heatmap, profile, plus one non-CPP plot).
  • Explicit figsize/fontsize_* passed by the user is honored regardless of auto_font.
  • tutorial3d + affected examples pass the nbmake gate with regenerated outputs.

Scope / non-goals (B)

  • General mechanism = font auto-optimization for all plots; size auto-scaling is the
    feature_map/dense-composite extension only (other plots keep their figsize).
  • No new dependencies; core only. auto_font is a global toggle, not a per-method knob explosion.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions