Source code for geolatent.rendering.surfaces

"""Decision-surface rendering for geolatent.

:class:`DecisionSurfaceRenderer` converts a :class:`~geolatent.core.mesh_builder.PredictionMesh`
into a set of Plotly traces that collectively visualise the geometry of a
model's decision function in 3-D projected space.

Three rendering strategies are implemented:

``render_probability_isosurfaces``
    For classifiers that expose ``predict_proba``.  Renders one Plotly
    ``Isosurface`` per class at the probability threshold of 0.50, producing
    clean translucent shells that exactly trace the decision boundary for each
    class in a one-vs-rest sense.  Additional shells at higher thresholds
    (0.70, 0.85) encode confidence depth.

``render_class_volumes``
    For classifiers without ``predict_proba`` (e.g., ``LinearSVC``).  Renders
    a single ``Volume`` trace coloured by predicted class index, giving a
    volumetric view of the class partition.

``render_regression_field``
    For regression models.  Renders a ``Volume`` trace with a diverging
    colorscale representing the scalar prediction field.

Strategy selection is automatic: the renderer inspects the :class:`PredictionMesh`
and chooses the most informative approach.
"""

from __future__ import annotations

from typing import Dict, List, Optional

import numpy as np
import plotly.graph_objects as go

from ..config.themes import VisualizationConfig
from ..core.mesh_builder import PredictionMesh


# Decision-surface renderer


[docs] class DecisionSurfaceRenderer: """Renders decision surfaces from a :class:`PredictionMesh`. Parameters ---------- config : VisualizationConfig Master configuration; colour palette and surface opacity are read here. Examples -------- >>> renderer = DecisionSurfaceRenderer(DARK_SCIENTIFIC) >>> traces = renderer.render(mesh) >>> for t in traces: ... scene.add_trace(t) """ def __init__(self, config: VisualizationConfig) -> None: self.config = config # Public dispatch
[docs] def render( self, mesh: PredictionMesh, *, class_names: Optional[Dict] = None, show_confidence: bool = True, ) -> List[go.BaseTraceType]: """Auto-select and execute the most appropriate rendering strategy. Parameters ---------- mesh : PredictionMesh Pre-computed prediction mesh from :class:`~geolatent.core.mesh_builder.MeshBuilder`. class_names : dict, optional Mapping from class label to display string. show_confidence : bool When ``True`` and ``mesh.probabilities`` is available, render nested confidence isosurfaces in addition to the boundary shell. Returns ------- traces : list of Plotly traces """ if mesh.is_regression: return self.render_regression_field(mesh) if mesh.probabilities is not None and show_confidence: return self.render_probability_isosurfaces( mesh, class_names=class_names, show_confidence=show_confidence ) return self.render_class_volumes(mesh, class_names=class_names)
# Probability isosurfaces (classifiers with predict_proba)
[docs] def render_probability_isosurfaces( self, mesh: PredictionMesh, *, class_names: Optional[Dict] = None, show_confidence: bool = True, boundary_threshold: float = 0.50, confidence_thresholds: Optional[List[float]] = None, ) -> List[go.Isosurface]: """Render probability isosurfaces for each class. For each class *c*, the decision boundary surface (``P(class=c) = 0.5``) is rendered as a translucent shell. When *show_confidence* is ``True``, additional shells at higher probability thresholds convey confidence depth. Parameters ---------- mesh : PredictionMesh Must have ``probabilities`` populated. class_names : dict, optional show_confidence : bool boundary_threshold : float Primary isosurface probability value (default 0.50). confidence_thresholds : list of float, optional Additional isosurface levels drawn inside the boundary shell. Defaults to ``[0.70, 0.85]`` when *show_confidence* is ``True``. Returns ------- traces : list of go.Isosurface """ assert mesh.probabilities is not None, "probabilities must be populated" if confidence_thresholds is None: confidence_thresholds = [0.70, 0.85] if show_confidence else [] colors = self.config.colors.class_colors opacity = self.config.render.surface_opacity traces: List[go.Isosurface] = [] # Build threshold list: boundary first, then confidence shells (inner) thresholds = [boundary_threshold] + confidence_thresholds opacities = [opacity] + [opacity * 0.55 for _ in confidence_thresholds] for class_idx, cls in enumerate(mesh.unique_classes): # Probability of this class at each grid vertex prob_col = int( np.searchsorted( np.sort(np.unique(mesh.predictions)), cls, ) ) # Use the class index within probabilities matrix directly prob_col = class_idx p_values = mesh.probabilities[:, prob_col] color = colors[class_idx % len(colors)] label = self._class_label(cls, class_names) for thresh, op in zip(thresholds, opacities): # Skip if the threshold is not crossed anywhere in the field if p_values.max() <= thresh or p_values.min() >= thresh: continue surface_count = 1 is_boundary = thresh == boundary_threshold traces.append( go.Isosurface( x=mesh.x, y=mesh.y, z=mesh.z, value=p_values, isomin=float(thresh) - 1e-4, isomax=float(thresh) + 1e-4, surface=dict(count=surface_count, fill=1.0), caps=dict(x_show=False, y_show=False, z_show=False), colorscale=[[0, color], [1, color]], showscale=False, opacity=op, name=( f"{label} boundary" if is_boundary else f"{label} p={thresh:.0%}" ), hoverinfo="skip", flatshading=False, lighting=dict( ambient=0.6, diffuse=0.5, specular=0.15, roughness=0.7, fresnel=0.2, ), lightposition=dict(x=1000, y=1000, z=1000), showlegend=is_boundary, legendgroup=str(cls), ) ) return traces
# Class volume (classifiers without predict_proba)
[docs] def render_class_volumes( self, mesh: PredictionMesh, *, class_names: Optional[Dict] = None, ) -> List[go.BaseTraceType]: """Render a per-class volume for models without ``predict_proba``. Maps predicted class indices onto a discrete colour scale and renders the full volumetric class partition as a transparent ``go.Volume``. To give class boundaries a clear visual edge, one ``go.Isosurface`` per class boundary is additionally rendered. Parameters ---------- mesh : PredictionMesh class_names : dict, optional Returns ------- traces : list of Plotly traces """ colors = self.config.colors.class_colors opacity = self.config.render.surface_opacity traces: List[go.BaseTraceType] = [] # Map class labels to consecutive integers [0, n_classes) label_to_int = {cls: i for i, cls in enumerate(mesh.unique_classes)} pred_int = np.vectorize(label_to_int.get)(mesh.predictions).astype(float) # Build a discrete colorscale n = len(mesh.unique_classes) colorscale = [] for i, cls in enumerate(mesh.unique_classes): color = colors[i % len(colors)] lo = i / n hi = (i + 1) / n colorscale.extend([[lo, color], [hi, color]]) # Volume trace for overall class field traces.append( go.Volume( x=mesh.x, y=mesh.y, z=mesh.z, value=pred_int, isomin=0.0, isomax=float(n - 1), opacity=opacity * 0.4, surface_count=n, colorscale=colorscale, showscale=False, caps=dict(x_show=False, y_show=False, z_show=False), name="Decision regions", hoverinfo="skip", showlegend=False, ) ) # Isosurface at each class boundary for i, cls in enumerate(mesh.unique_classes): color = colors[i % len(colors)] label = self._class_label(cls, class_names) boundary_val = float(i) + 0.5 if i == 0: continue # no lower boundary for first class traces.append( go.Isosurface( x=mesh.x, y=mesh.y, z=mesh.z, value=pred_int, isomin=boundary_val - 1e-4, isomax=boundary_val + 1e-4, surface=dict(count=1, fill=1.0), caps=dict(x_show=False, y_show=False, z_show=False), colorscale=[[0, color], [1, color]], showscale=False, opacity=opacity, name=f"{label} boundary", hoverinfo="skip", lighting=dict( ambient=0.6, diffuse=0.5, specular=0.1, roughness=0.8, ), showlegend=True, ) ) return traces
# Regression field
[docs] def render_regression_field(self, mesh: PredictionMesh) -> List[go.Volume]: """Render a continuous regression prediction field as a volumetric trace. Parameters ---------- mesh : PredictionMesh A mesh whose ``is_regression`` flag is ``True``. Returns ------- traces : list of go.Volume """ values = mesh.predictions.astype(float) vmin, vmax = float(values.min()), float(values.max()) return [ go.Volume( x=mesh.x, y=mesh.y, z=mesh.z, value=values, isomin=vmin, isomax=vmax, opacity=self.config.render.surface_opacity * 0.6, surface_count=12, colorscale=self.config.colors.surface_colorscale, showscale=self.config.render.show_colorbar, colorbar=dict( title="Prediction", tickfont=dict( color=self.config.colors.annotation_color, family=self.config.render.font_family, ), titlefont=dict( color=self.config.colors.text, family=self.config.render.font_family, ), bgcolor="rgba(22,27,34,0.8)", bordercolor=self.config.colors.axis_line, borderwidth=1, ), caps=dict(x_show=False, y_show=False, z_show=False), name="Regression field", hoverinfo="skip", showlegend=True, ) ]
# Private helpers @staticmethod def _class_label(cls: object, class_names: Optional[Dict]) -> str: if class_names and cls in class_names: return str(class_names[cls]) return f"Class {cls}"