dataeval-plots 0.0.15__tar.gz → 0.0.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (17) hide show
  1. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/PKG-INFO +1 -1
  2. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/__init__.py +10 -0
  3. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/_version.py +2 -2
  4. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/_altair.py +78 -0
  5. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/_base.py +20 -0
  6. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/_matplotlib.py +32 -0
  7. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/_plotly.py +109 -0
  8. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/_seaborn.py +44 -0
  9. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/_shared.py +291 -4
  10. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/protocols.py +82 -5
  11. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/.gitignore +0 -0
  12. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/README.md +0 -0
  13. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/pyproject.toml +0 -0
  14. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/_registry.py +0 -0
  15. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/__init__.py +0 -0
  16. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/backends/_feasibility_layout.py +0 -0
  17. {dataeval_plots-0.0.15 → dataeval_plots-0.0.17}/src/dataeval_plots/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dataeval-plots
3
- Version: 0.0.15
3
+ Version: 0.0.17
4
4
  Summary: DataEval companion package for plotting utilities
5
5
  Project-URL: Homepage, https://dataeval.ai/
6
6
  Project-URL: Repository, https://github.com/aria-ml/dataeval/
@@ -19,6 +19,7 @@ from dataeval_plots.protocols import (
19
19
  Dataset,
20
20
  PlottableBalance,
21
21
  PlottableDiversity,
22
+ PlottableDrift,
22
23
  PlottableDriftMVDC,
23
24
  PlottableFeasibility,
24
25
  PlottableStats,
@@ -98,6 +99,15 @@ def plot(
98
99
  ) -> Any: ...
99
100
 
100
101
 
102
+ @overload
103
+ def plot(
104
+ output: PlottableDrift,
105
+ /,
106
+ figsize: tuple[float, float] | None = None,
107
+ backend: str | None = None,
108
+ ) -> Any: ...
109
+
110
+
101
111
  @overload
102
112
  def plot(
103
113
  output: PlottableFeasibility,
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.15'
22
- __version_tuple__ = version_tuple = (0, 0, 15)
21
+ __version__ = version = '0.0.17'
22
+ __version_tuple__ = version_tuple = (0, 0, 17)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -29,6 +29,7 @@ from dataeval_plots.backends._shared import (
29
29
  normalize_reference_outputs,
30
30
  parse_dataset_item,
31
31
  prepare_balance_data,
32
+ prepare_chunked_drift,
32
33
  prepare_diversity_data,
33
34
  prepare_drift_data,
34
35
  prepare_feasibility_data,
@@ -39,6 +40,7 @@ from dataeval_plots.protocols import (
39
40
  Dataset,
40
41
  PlottableBalance,
41
42
  PlottableDiversity,
43
+ PlottableDrift,
42
44
  PlottableDriftMVDC,
43
45
  PlottableFeasibility,
44
46
  PlottableStats,
@@ -819,6 +821,82 @@ class AltairBackend(BasePlottingBackend):
819
821
  width=width, height=height, title="Domain Classifier, Drift Detection"
820
822
  )
821
823
 
824
+ def _plot_drift(
825
+ self,
826
+ output: PlottableDrift,
827
+ figsize: tuple[float, float] | None = None,
828
+ ) -> Any: # alt.LayerChart
829
+ """
830
+ Render the metric of each chunk in relation to the thresholds of the detector.
831
+
832
+ When the output carries the metric of the reference chunks, they are drawn in gray to the left of the
833
+ analysis chunks. The chunks are numbered from -n for the reference and from 0 for the analysis.
834
+
835
+ Parameters
836
+ ----------
837
+ output : PlottableDrift
838
+ The chunked drift output object to plot
839
+ figsize : tuple[float, float] or None, default None
840
+ Figure size in inches (width, height), drawn at 100 pixels per inch. If None, defaults to (6, 3).
841
+
842
+ Returns
843
+ -------
844
+ alt.LayerChart
845
+ """
846
+ import altair as alt
847
+ import pandas as pd
848
+
849
+ data = prepare_chunked_drift(output)
850
+ width_inches, height_inches = figsize or (6, 3)
851
+
852
+ # Convert via dict to avoid pyarrow dependency
853
+ plot_df = pd.DataFrame(
854
+ data.frame.with_columns(
855
+ pl.when(pl.col("period") == "reference")
856
+ .then(pl.lit("Reference chunks"))
857
+ .otherwise(pl.lit("Analysis chunks"))
858
+ .alias("period")
859
+ ).to_dict(as_series=False)
860
+ )
861
+
862
+ x = alt.X("index:Q", title=data.chunk_label, axis=alt.Axis(tickMinStep=1))
863
+ scale = alt.Scale(zero=False)
864
+ y = alt.Y("value:Q", title=data.metric_name, scale=scale)
865
+ tooltip = ["index:Q", alt.Tooltip("value:Q", title=data.metric_name, format=".4f")]
866
+
867
+ # The no-drift range is the band between the two thresholds
868
+ layers = [
869
+ alt.Chart(plot_df)
870
+ .mark_area(color="green", opacity=0.15, interpolate="step")
871
+ .encode(x=x, y=alt.Y("lower_threshold:Q", title=data.metric_name, scale=scale), y2="upper_threshold:Q")
872
+ ]
873
+
874
+ # The metric of each chunk, with the reference chunks in gray
875
+ has_reference = bool((plot_df["period"] == "Reference chunks").any())
876
+ color = alt.Color(
877
+ "period:N",
878
+ scale=alt.Scale(domain=["Reference chunks", "Analysis chunks"], range=["gray", "royalblue"]),
879
+ legend=alt.Legend(title=None),
880
+ )
881
+ layers.append(alt.Chart(plot_df).mark_line(point=True).encode(x=x, y=y, color=color, tooltip=tooltip))
882
+ if has_reference:
883
+ layers.append(
884
+ alt.Chart(pd.DataFrame({"index": [-0.5]}))
885
+ .mark_rule(color="gray", strokeDash=[2, 2])
886
+ .encode(x="index:Q")
887
+ )
888
+
889
+ # The chunks that the detector flagged
890
+ layers.append(
891
+ alt.Chart(plot_df[plot_df["drifted"]]) # type: ignore[arg-type]
892
+ .mark_point(shape="diamond", size=120, color="crimson", filled=True)
893
+ .encode(x=x, y=y, tooltip=tooltip)
894
+ )
895
+
896
+ return alt.layer(*layers).properties(
897
+ width=int(width_inches * 100), height=int(height_inches * 100), title="Drift by chunk"
898
+ )
899
+
822
900
  def _plot_image_grid(
823
901
  self,
824
902
  dataset: Dataset,
@@ -14,6 +14,7 @@ from dataeval_plots.protocols import (
14
14
  Dataset,
15
15
  PlottableBalance,
16
16
  PlottableDiversity,
17
+ PlottableDrift,
17
18
  PlottableDriftMVDC,
18
19
  PlottableFeasibility,
19
20
  PlottableStats,
@@ -81,6 +82,14 @@ class PlottingBackend(Protocol):
81
82
  figsize: tuple[float, float] | None = None,
82
83
  ) -> Any: ...
83
84
 
85
+ @overload
86
+ def plot(
87
+ self,
88
+ output: PlottableDrift,
89
+ *,
90
+ figsize: tuple[float, float] | None = None,
91
+ ) -> Any: ...
92
+
84
93
  @overload
85
94
  def plot(
86
95
  self,
@@ -245,6 +254,8 @@ class BasePlottingBackend(PlottingBackend, ABC):
245
254
  return self._plot_sufficiency(cast(PlottableSufficiency, output), figsize=figsize, **kwargs)
246
255
  if plot_type == "drift_mvdc":
247
256
  return self._plot_drift_mvdc(cast(PlottableDriftMVDC, output), figsize=figsize, **kwargs)
257
+ if plot_type == "drift":
258
+ return self._plot_drift(cast(PlottableDrift, output), figsize=figsize, **kwargs)
248
259
  if plot_type == "stats":
249
260
  return self._plot_stats(cast(PlottableStats, output), figsize=figsize, **kwargs)
250
261
  if plot_type == "feasibility":
@@ -310,6 +321,15 @@ class BasePlottingBackend(PlottingBackend, ABC):
310
321
  """Plot drift MVDC output."""
311
322
  ...
312
323
 
324
+ @abstractmethod
325
+ def _plot_drift(
326
+ self,
327
+ output: PlottableDrift,
328
+ figsize: tuple[float, float] | None = None,
329
+ ) -> Any:
330
+ """Plot the per-chunk results of a chunked drift detector."""
331
+ ...
332
+
313
333
  @abstractmethod
314
334
  def _plot_feasibility(
315
335
  self,
@@ -21,8 +21,10 @@ from dataeval_plots.backends._shared import (
21
21
  calculate_subplot_grid,
22
22
  feasibility_zones,
23
23
  normalize_reference_outputs,
24
+ plot_chunked_drift_on_axis,
24
25
  plot_drift_on_axis,
25
26
  prepare_balance_data,
27
+ prepare_chunked_drift,
26
28
  prepare_diversity_data,
27
29
  prepare_drift_data,
28
30
  prepare_feasibility_data,
@@ -32,6 +34,7 @@ from dataeval_plots.backends._shared import (
32
34
  from dataeval_plots.protocols import (
33
35
  PlottableBalance,
34
36
  PlottableDiversity,
37
+ PlottableDrift,
35
38
  PlottableDriftMVDC,
36
39
  PlottableFeasibility,
37
40
  PlottableStats,
@@ -833,6 +836,35 @@ class MatplotlibBackend(BasePlottingBackend):
833
836
  )
834
837
  return fig
835
838
 
839
+ def _plot_drift(
840
+ self,
841
+ output: PlottableDrift,
842
+ figsize: tuple[float, float] | None = None,
843
+ ) -> Figure:
844
+ """
845
+ Render the metric of each chunk in relation to the thresholds of the detector.
846
+
847
+ Parameters
848
+ ----------
849
+ output : PlottableDrift
850
+ The chunked drift output object to plot
851
+ figsize : tuple[float, float] or None, default None
852
+ Figure size in inches (width, height). If None, defaults to (8, 3.5).
853
+
854
+ Returns
855
+ -------
856
+ matplotlib.figure.Figure
857
+ """
858
+ import matplotlib.pyplot as plt
859
+
860
+ data = prepare_chunked_drift(output)
861
+
862
+ fig, ax = plt.subplots(figsize=figsize or (8, 3.5))
863
+ plot_chunked_drift_on_axis(ax, data)
864
+ ax.set_title("Drift by chunk")
865
+ fig.tight_layout()
866
+ return fig
867
+
836
868
  def _plot_feasibility(
837
869
  self,
838
870
  output: PlottableFeasibility,
@@ -22,6 +22,7 @@ from dataeval_plots.backends._shared import (
22
22
  feasibility_zones,
23
23
  format_label_from_target,
24
24
  prepare_balance_data,
25
+ prepare_chunked_drift,
25
26
  prepare_diversity_data,
26
27
  prepare_drift_data,
27
28
  prepare_feasibility_data,
@@ -33,6 +34,7 @@ from dataeval_plots.protocols import (
33
34
  Dataset,
34
35
  PlottableBalance,
35
36
  PlottableDiversity,
37
+ PlottableDrift,
36
38
  PlottableDriftMVDC,
37
39
  PlottableFeasibility,
38
40
  PlottableStats,
@@ -849,6 +851,113 @@ class PlotlyBackend(BasePlottingBackend):
849
851
 
850
852
  return fig
851
853
 
854
+ def _plot_drift(
855
+ self,
856
+ output: PlottableDrift,
857
+ figsize: tuple[float, float] | None = None,
858
+ ) -> Any: # go.Figure
859
+ """
860
+ Render the metric of each chunk in relation to the thresholds of the detector.
861
+
862
+ When the output carries the metric of the reference chunks, they are drawn in gray to the left of the
863
+ analysis chunks. The chunks are numbered from -n for the reference and from 0 for the analysis.
864
+
865
+ Parameters
866
+ ----------
867
+ output : PlottableDrift
868
+ The chunked drift output object to plot
869
+ figsize : tuple[float, float] or None, default None
870
+ Figure size in inches (width, height), drawn at 100 pixels per inch. If None, defaults to (8, 3.5).
871
+
872
+ Returns
873
+ -------
874
+ plotly.graph_objects.Figure
875
+ """
876
+ import plotly.graph_objects as go
877
+
878
+ data = prepare_chunked_drift(output)
879
+ frame, reference, analysis = data.frame, data.reference, data.analysis
880
+ drifted = analysis.filter(pl.col("drifted"))
881
+ hover = f"Chunk: %{{x}}<br>{data.metric_name}: %{{y:.4f}}<extra></extra>"
882
+
883
+ fig = go.Figure()
884
+
885
+ # The no-drift range is the band between the two thresholds
886
+ fig.add_trace(
887
+ go.Scatter(
888
+ x=frame["index"].to_numpy(),
889
+ y=frame["upper_threshold"].to_numpy(),
890
+ mode="lines",
891
+ line={"width": 0, "shape": "hvh"},
892
+ showlegend=False,
893
+ hoverinfo="skip",
894
+ )
895
+ )
896
+ fig.add_trace(
897
+ go.Scatter(
898
+ x=frame["index"].to_numpy(),
899
+ y=frame["lower_threshold"].to_numpy(),
900
+ mode="lines",
901
+ line={"width": 0, "shape": "hvh"},
902
+ fill="tonexty",
903
+ fillcolor="rgba(44, 160, 44, 0.15)",
904
+ name="No-drift range",
905
+ hoverinfo="skip",
906
+ )
907
+ )
908
+
909
+ # The reference chunks, which the thresholds derive from
910
+ if len(reference):
911
+ fig.add_trace(
912
+ go.Scatter(
913
+ x=reference["index"].to_numpy(),
914
+ y=reference["value"].to_numpy(),
915
+ mode="lines+markers",
916
+ name="Reference chunks",
917
+ line={"color": "gray", "width": 2},
918
+ hovertemplate=hover,
919
+ )
920
+ )
921
+ fig.add_vline(x=-0.5, line_dash="dot", line_color="gray", line_width=1)
922
+
923
+ # The metric of each analysis chunk
924
+ fig.add_trace(
925
+ go.Scatter(
926
+ x=analysis["index"].to_numpy(),
927
+ y=analysis["value"].to_numpy(),
928
+ mode="lines+markers",
929
+ name="Analysis chunks",
930
+ line={"color": "royalblue", "width": 2},
931
+ hovertemplate=hover,
932
+ )
933
+ )
934
+
935
+ # The chunks that the detector flagged
936
+ if len(drifted):
937
+ fig.add_trace(
938
+ go.Scatter(
939
+ x=drifted["index"].to_numpy(),
940
+ y=drifted["value"].to_numpy(),
941
+ mode="markers",
942
+ name="Drift",
943
+ marker={"symbol": "diamond", "size": 11, "color": "crimson"},
944
+ hovertemplate=hover,
945
+ )
946
+ )
947
+
948
+ width_inches, height_inches = figsize or (8, 3.5)
949
+ fig.update_layout(
950
+ title="Drift by chunk",
951
+ xaxis_title=data.chunk_label,
952
+ yaxis_title=data.metric_name,
953
+ xaxis={"dtick": 1} if len(frame) <= 20 else {},
954
+ width=int(width_inches * 100),
955
+ height=int(height_inches * 100),
956
+ hovermode="closest",
957
+ )
958
+
959
+ return fig
960
+
852
961
  def _plot_image_grid(
853
962
  self,
854
963
  dataset: Dataset,
@@ -10,14 +10,17 @@ from numpy.typing import NDArray
10
10
  from dataeval_plots.backends._base import BasePlottingBackend
11
11
  from dataeval_plots.backends._shared import (
12
12
  CHANNELWISE_METRICS,
13
+ plot_chunked_drift_on_axis,
13
14
  plot_drift_on_axis,
14
15
  prepare_balance_data,
16
+ prepare_chunked_drift,
15
17
  prepare_diversity_data,
16
18
  prepare_drift_data,
17
19
  )
18
20
  from dataeval_plots.protocols import (
19
21
  PlottableBalance,
20
22
  PlottableDiversity,
23
+ PlottableDrift,
21
24
  PlottableDriftMVDC,
22
25
  PlottableFeasibility,
23
26
  PlottableStats,
@@ -493,6 +496,47 @@ class SeabornBackend(BasePlottingBackend):
493
496
  fig.tight_layout()
494
497
  return fig
495
498
 
499
+ def _plot_drift(
500
+ self,
501
+ output: PlottableDrift,
502
+ figsize: tuple[float, float] | None = None,
503
+ ) -> Figure:
504
+ """
505
+ Render the metric of each chunk in relation to the thresholds of the detector.
506
+
507
+ Parameters
508
+ ----------
509
+ output : PlottableDrift
510
+ The chunked drift output object to plot
511
+ figsize : tuple[float, float] or None, default None
512
+ Figure size in inches (width, height). If None, defaults to (10, 4).
513
+
514
+ Returns
515
+ -------
516
+ matplotlib.figure.Figure
517
+ """
518
+ import matplotlib.pyplot as plt
519
+ import seaborn as sns
520
+
521
+ sns.set_style("whitegrid")
522
+ palette = sns.color_palette("deep") # not the global palette, which a user can shorten
523
+
524
+ data = prepare_chunked_drift(output)
525
+
526
+ fig, ax = plt.subplots(figsize=figsize or (10, 4))
527
+ plot_chunked_drift_on_axis(
528
+ ax,
529
+ data,
530
+ line_color=palette[0],
531
+ band_color=palette[2],
532
+ drift_color=palette[3],
533
+ reference_color=palette[7],
534
+ )
535
+ ax.set_title("Drift by chunk")
536
+ sns.despine(ax=ax)
537
+ fig.tight_layout()
538
+ return fig
539
+
496
540
  def _plot_feasibility(
497
541
  self,
498
542
  output: PlottableFeasibility,
@@ -15,12 +15,16 @@ import numpy as np
15
15
  import polars as pl
16
16
  from numpy.typing import NDArray
17
17
 
18
+ from dataeval_plots.protocols import PlottableFeasibilityDecisions
19
+
18
20
  if TYPE_CHECKING:
19
21
  from dataeval_plots.protocols import (
20
22
  PlottableBalance,
21
23
  PlottableDiversity,
24
+ PlottableDrift,
22
25
  PlottableDriftMVDC,
23
26
  PlottableFeasibility,
27
+ PlottableFeasibilityDeterminations,
24
28
  )
25
29
 
26
30
  MethodType = Literal[
@@ -46,6 +50,10 @@ __all__ = [
46
50
  "prepare_diversity_data",
47
51
  "prepare_drift_data",
48
52
  "plot_drift_on_axis",
53
+ "CHUNKED_DRIFT_COLUMNS",
54
+ "ChunkedDriftData",
55
+ "prepare_chunked_drift",
56
+ "plot_chunked_drift_on_axis",
49
57
  "FEASIBILITY_COLORS",
50
58
  "FEASIBILITY_CONFLICT_TEXT",
51
59
  "FeasibilityBand",
@@ -498,6 +506,200 @@ def prepare_drift_data(
498
506
  return resdf, trndf, tstdf, driftx, True
499
507
 
500
508
 
509
+ CHUNKED_DRIFT_COLUMNS = ("index", "value", "lower_threshold", "upper_threshold", "drifted")
510
+
511
+
512
+ @dataclass(frozen=True)
513
+ class ChunkedDriftData:
514
+ """
515
+ The per-chunk results of a chunked drift detector, ready for plotting.
516
+
517
+ Attributes
518
+ ----------
519
+ frame : pl.DataFrame
520
+ One row per chunk with the columns ``index``, ``value``, ``lower_threshold``, ``upper_threshold``,
521
+ ``drifted`` and ``period``. The rows of the reference chunks come first, numbered from ``-n`` to ``-1``
522
+ so that the analysis chunks keep their own indexes, and have ``period`` set to ``"reference"``. The
523
+ analysis chunks have ``period`` set to ``"analysis"``.
524
+ metric_name : str
525
+ Name of the metric that ``value`` reports
526
+ chunk_label : str
527
+ Label for the axis of chunks, which includes the size of a chunk when every analysis chunk has the same size
528
+ """
529
+
530
+ frame: pl.DataFrame
531
+ metric_name: str
532
+ chunk_label: str
533
+
534
+ @property
535
+ def reference(self) -> pl.DataFrame:
536
+ """The rows of the reference chunks, which is empty when the output carries no baseline."""
537
+ return self.frame.filter(pl.col("period") == "reference")
538
+
539
+ @property
540
+ def analysis(self) -> pl.DataFrame:
541
+ """The rows of the analysis chunks."""
542
+ return self.frame.filter(pl.col("period") == "analysis")
543
+
544
+
545
+ def prepare_chunked_drift(output: PlottableDrift) -> ChunkedDriftData:
546
+ """
547
+ Read the per-chunk table, the reference chunks and the metric name from a chunked drift output.
548
+
549
+ The reference chunks are the optional ``baseline`` attribute of the output, which holds the metric of each
550
+ reference chunk. An output without it, such as one from an earlier version of DataEval, has no reference rows
551
+ and the plot leaves them out.
552
+
553
+ Parameters
554
+ ----------
555
+ output : PlottableDrift
556
+ The drift output object to plot
557
+
558
+ Returns
559
+ -------
560
+ ChunkedDriftData
561
+ The per-chunk results. A threshold that is missing, as for a one-sided detector, is replaced by a bound just
562
+ beyond the data, so that the range of no drift has no limit on that side.
563
+
564
+ Raises
565
+ ------
566
+ ValueError
567
+ If the output holds the statistics of a single test and not a per-chunk table, or if the table lacks a column
568
+ that the plot needs
569
+ """
570
+ details = output.details
571
+ if not isinstance(details, pl.DataFrame):
572
+ raise ValueError("Plotting drift needs chunked results. Call .chunked() on the detector before predict().")
573
+ missing = [name for name in CHUNKED_DRIFT_COLUMNS if name not in details.columns]
574
+ if missing:
575
+ raise ValueError(f"The per-chunk drift results lack the columns {missing}.")
576
+
577
+ analysis = details.select(
578
+ pl.col("index").cast(pl.Int64),
579
+ pl.col("value", "lower_threshold", "upper_threshold").cast(pl.Float64),
580
+ pl.col("drifted").cast(pl.Boolean),
581
+ pl.lit("analysis").alias("period"),
582
+ )
583
+
584
+ # The thresholds come from the reference chunks, so the band spans them too
585
+ baseline = np.asarray(getattr(output, "baseline", None) or (), dtype=np.float64)
586
+ if len(baseline) and len(analysis):
587
+ reference = pl.DataFrame(
588
+ {
589
+ "index": np.arange(-len(baseline), 0, dtype=np.int64),
590
+ "value": baseline,
591
+ "lower_threshold": [analysis["lower_threshold"][0]] * len(baseline),
592
+ "upper_threshold": [analysis["upper_threshold"][0]] * len(baseline),
593
+ "drifted": [False] * len(baseline),
594
+ "period": ["reference"] * len(baseline),
595
+ },
596
+ schema=analysis.schema,
597
+ )
598
+ frame = pl.concat([reference, analysis])
599
+ else:
600
+ frame = analysis
601
+
602
+ if frame["lower_threshold"].null_count() or frame["upper_threshold"].null_count():
603
+ extent = pl.concat([frame["value"], frame["lower_threshold"], frame["upper_threshold"]]).drop_nulls()
604
+ low, high = extent.min(), extent.max()
605
+ pad = 0.1 * (high - low) if low != high else 1.0 # type: ignore[operator]
606
+ frame = frame.with_columns(
607
+ pl.col("lower_threshold").fill_null(low - pad), # type: ignore[operator]
608
+ pl.col("upper_threshold").fill_null(high + pad), # type: ignore[operator]
609
+ )
610
+
611
+ return ChunkedDriftData(frame, output.metric_name, _chunk_label(details))
612
+
613
+
614
+ def _chunk_label(details: pl.DataFrame) -> str:
615
+ """Label the axis of chunks with the size of a chunk, when every chunk has the same size."""
616
+ if "start_index" in details.columns and "end_index" in details.columns and len(details):
617
+ sizes = (details["end_index"] - details["start_index"] + 1).unique()
618
+ if len(sizes) == 1 and (details["start_index"] >= 0).all():
619
+ return f"Chunk ({sizes[0]} samples each)"
620
+ return "Chunk"
621
+
622
+
623
+ def plot_chunked_drift_on_axis(
624
+ ax: Any,
625
+ data: ChunkedDriftData,
626
+ line_color: Any = "tab:blue",
627
+ band_color: Any = "tab:green",
628
+ drift_color: Any = "tab:red",
629
+ reference_color: Any = "tab:gray",
630
+ linewidth: float = 1.5,
631
+ ) -> None:
632
+ """
633
+ Plot the metric of each chunk against the threshold range of the detector on a matplotlib axis.
634
+
635
+ This is a shared helper for the matplotlib and seaborn backends. The metric is a line with a marker per chunk,
636
+ the band is the range between the lower and upper thresholds in which a chunk counts as no drift, and a diamond
637
+ marks each chunk that the detector flagged. When the output carries the metric of the reference chunks, they are
638
+ drawn in a second color to the left of the analysis chunks, with a dotted line between them.
639
+
640
+ Parameters
641
+ ----------
642
+ ax : matplotlib.axes.Axes
643
+ Axis to plot on
644
+ data : ChunkedDriftData
645
+ Per-chunk results from :func:`prepare_chunked_drift`
646
+ line_color : Any, default "tab:blue"
647
+ Color of the metric line of the analysis chunks
648
+ band_color : Any, default "tab:green"
649
+ Color of the band between the thresholds
650
+ drift_color : Any, default "tab:red"
651
+ Color of the markers on the chunks with drift
652
+ reference_color : Any, default "tab:gray"
653
+ Color of the metric line of the reference chunks and of the line that separates them
654
+ linewidth : float, default 1.5
655
+ Width of the metric lines
656
+ """
657
+ from matplotlib.ticker import MaxNLocator
658
+
659
+ frame, reference, analysis = data.frame, data.reference, data.analysis
660
+ ax.fill_between(
661
+ frame["index"].to_numpy(),
662
+ frame["lower_threshold"].to_numpy(),
663
+ frame["upper_threshold"].to_numpy(),
664
+ step="mid",
665
+ color=band_color,
666
+ alpha=0.15,
667
+ label="No-drift range",
668
+ )
669
+ if len(reference):
670
+ ax.plot(
671
+ reference["index"].to_numpy(),
672
+ reference["value"].to_numpy(),
673
+ marker="o",
674
+ color=reference_color,
675
+ linewidth=linewidth,
676
+ label="Reference chunks",
677
+ )
678
+ ax.axvline(-0.5, color=reference_color, linestyle=":", linewidth=1)
679
+ ax.plot(
680
+ analysis["index"].to_numpy(),
681
+ analysis["value"].to_numpy(),
682
+ marker="o",
683
+ color=line_color,
684
+ linewidth=linewidth,
685
+ label="Analysis chunks",
686
+ )
687
+ drifted = analysis.filter(pl.col("drifted"))
688
+ if len(drifted):
689
+ ax.scatter(
690
+ drifted["index"].to_numpy(),
691
+ drifted["value"].to_numpy(),
692
+ marker="D",
693
+ color=drift_color,
694
+ zorder=3,
695
+ label="Drift",
696
+ )
697
+ ax.xaxis.set_major_locator(MaxNLocator(integer=True))
698
+ ax.set_xlabel(data.chunk_label)
699
+ ax.set_ylabel(data.metric_name)
700
+ ax.legend(loc="best")
701
+
702
+
501
703
  def plot_drift_on_axis(
502
704
  ax: Any,
503
705
  resdf: Any,
@@ -982,12 +1184,19 @@ def _feasibility_band(
982
1184
  )
983
1185
 
984
1186
 
985
- # These mirror DataEval's performance/_feasibility.py: _SOURCES, _source_name, _unit_noun and FeasibilityOutput._bounds
1187
+ # The readable name of each source: by DataEval 1.2.0's evidence id (these mirror its performance/_feasibility.py:
1188
+ # _SOURCES, _source_name, _unit_noun and FeasibilityOutput._bounds), and by the `source` value of the decisions and
1189
+ # bounds shape. Any other value shows its own text.
986
1190
  _SOURCE_NAMES = {
987
1191
  "replica_ceiling/v1": "repeated labels",
988
1192
  "builtin_witness/v1:knn": "built-in k-NN",
989
1193
  "builtin_witness/v1:probe": "built-in linear probe",
990
1194
  "user_witness/v1": "your model",
1195
+ "local_panels": _GROUP_CEILING,
1196
+ "replicas": "repeated labels",
1197
+ "knn": "built-in k-NN",
1198
+ "linear_probe": "built-in linear probe",
1199
+ "user_model": "your model",
991
1200
  }
992
1201
  _EVIDENCE_SCHEMA = {
993
1202
  "target_spec": pl.String,
@@ -1009,7 +1218,7 @@ _EVIDENCE_SCHEMA = {
1009
1218
  }
1010
1219
 
1011
1220
 
1012
- def _evidence_rows(output: PlottableFeasibility) -> pl.DataFrame:
1221
+ def _evidence_rows(output: PlottableFeasibilityDeterminations) -> pl.DataFrame:
1013
1222
  """One row per endpoint on a target in ``determinations``, in their order, then the order of ``evidence``."""
1014
1223
  targets = {r["target_spec"]: r for r in output.determinations.iter_rows(named=True)}
1015
1224
  rows = []
@@ -1049,6 +1258,84 @@ def _evidence_rows(output: PlottableFeasibility) -> pl.DataFrame:
1049
1258
  return pl.DataFrame(rows, schema=_EVIDENCE_SCHEMA)
1050
1259
 
1051
1260
 
1261
+ def _decisions_inputs(output: PlottableFeasibilityDecisions) -> tuple[list[dict[str, Any]], pl.DataFrame]:
1262
+ """
1263
+ Read the decisions and bounds shape as the determination rows and evidence frame the bands are built from.
1264
+
1265
+ The rows use the determinations shape's vocabulary, so everything downstream reads either shape alike: an
1266
+ unresolved decision with limitation ``conflict`` is an ``evidence_conflict``, and ``envelope`` is a ``ceiling``.
1267
+ """
1268
+ decisions = list(output.decisions.iter_rows(named=True))
1269
+ bounds = list(output.bounds.iter_rows(named=True))
1270
+ rows: list[dict[str, Any]] = []
1271
+ evidence: list[dict[str, Any]] = []
1272
+ for d in decisions:
1273
+ # ponytail: a private join key in the old reader's vocabulary; typed keys replace it when that reader goes
1274
+ spec = json.dumps(
1275
+ {
1276
+ "metric": d["metric"],
1277
+ "scope": d["judged_scope"],
1278
+ "class_name": d["class_name"],
1279
+ "aggregation": d["aggregation"],
1280
+ "ap_functional": d["ap_functional"],
1281
+ "max_false_positive_rate": d["max_false_positive_rate"],
1282
+ "threshold": d["threshold"],
1283
+ },
1284
+ sort_keys=True,
1285
+ )
1286
+ conflict = d["decision"] == "unresolved" and d["limitation"] == "conflict"
1287
+ rows.append(
1288
+ {
1289
+ "target_spec": spec,
1290
+ "metric": d["metric"],
1291
+ "threshold": d["threshold"],
1292
+ "judged_scope": d["judged_scope"],
1293
+ "determination": "evidence_conflict" if conflict else d["decision"],
1294
+ "ceiling": d["envelope"],
1295
+ "ceiling_scope": d["envelope_scope"],
1296
+ "witness": d["witness"],
1297
+ "witness_source": d["witness_source"],
1298
+ }
1299
+ )
1300
+ mine = [b for b in bounds if b["requirement"] == d["requirement"]]
1301
+ f0 = d["max_false_positive_rate"]
1302
+ for b in mine:
1303
+ q = _FALSE_POSITIVE if b["quantity"] == _FALSE_POSITIVE else "metric"
1304
+ fp = next(
1305
+ (x["bound"] for x in mine if x["source"] == b["source"] and x["quantity"] == _FALSE_POSITIVE), None
1306
+ )
1307
+ # A recall witness counts only when its own false-positive upper bound is within the limit
1308
+ uncertified = q == "metric" and b["bound_type"] == "lower" and f0 is not None and (fp is None or fp > f0)
1309
+ evidence.append(
1310
+ {
1311
+ "target_spec": spec,
1312
+ "metric": d["metric"],
1313
+ "threshold": d["threshold"],
1314
+ "source": _SOURCE_NAMES.get(b["source"], b["source"]),
1315
+ "evidence_id": b["source"],
1316
+ "information_scope": b["information_scope"],
1317
+ "unit": b["unit"],
1318
+ "bound_type": b["bound_type"],
1319
+ "observed": b["observed"],
1320
+ "bound": b["bound"],
1321
+ "n": b["n"],
1322
+ "state": b["state"],
1323
+ "quantity": q,
1324
+ "uncertified": uncertified,
1325
+ "false_positive_bound": fp,
1326
+ "false_positive_limit": f0,
1327
+ }
1328
+ )
1329
+ return rows, pl.DataFrame(evidence, schema=_EVIDENCE_SCHEMA)
1330
+
1331
+
1332
+ def _feasibility_inputs(output: PlottableFeasibility) -> tuple[list[dict[str, Any]], pl.DataFrame]:
1333
+ """The determination rows and evidence frame of a feasibility output in either shape."""
1334
+ if isinstance(output, PlottableFeasibilityDecisions):
1335
+ return _decisions_inputs(output)
1336
+ return list(output.determinations.iter_rows(named=True)), _evidence_rows(output)
1337
+
1338
+
1052
1339
  def prepare_feasibility_data(output: PlottableFeasibility) -> list[FeasibilityBand]:
1053
1340
  """
1054
1341
  Prepare feasibility data for plotting across all backends.
@@ -1066,13 +1353,13 @@ def prepare_feasibility_data(output: PlottableFeasibility) -> list[FeasibilityBa
1066
1353
  list[FeasibilityBand]
1067
1354
  One band per quantity, in the order the determinations first name it
1068
1355
  """
1069
- rows = list(output.determinations.iter_rows(named=True))
1356
+ rows, evidence_frame = _feasibility_inputs(output)
1070
1357
  groups: dict[str, list[dict[str, Any]]] = {}
1071
1358
  for row in rows:
1072
1359
  spec = json.loads(row["target_spec"])
1073
1360
  spec.pop("threshold", None)
1074
1361
  groups.setdefault(json.dumps(spec, sort_keys=True), []).append(row)
1075
- evidence = _evidence_rows(output).to_dicts()
1362
+ evidence = evidence_frame.to_dicts()
1076
1363
  alpha = output.meta().state["alpha"]
1077
1364
  noun = _unit(rows, [e for e in evidence if e["quantity"] == "metric"]) or "image" # the printout's footer noun
1078
1365
  bands = []
@@ -12,7 +12,10 @@ The protocol hierarchy:
12
12
  - PlottableSufficiency
13
13
  - PlottableStats
14
14
  - PlottableDriftMVDC
15
- - PlottableFeasibility
15
+ - PlottableDrift
16
+ - PlottableFeasibilityDeterminations
17
+ - PlottableFeasibilityDecisions
18
+ - PlottableFeasibility (either of the two feasibility shapes)
16
19
  """
17
20
 
18
21
  from __future__ import annotations
@@ -122,13 +125,15 @@ class Plottable(Protocol):
122
125
  """
123
126
 
124
127
  @property
125
- def plot_type(self) -> Literal["balance", "diversity", "sufficiency", "stats", "drift_mvdc", "feasibility"]:
128
+ def plot_type(
129
+ self,
130
+ ) -> Literal["balance", "diversity", "sufficiency", "stats", "drift_mvdc", "drift", "feasibility"]:
126
131
  """Return the plot type identifier for routing to appropriate plot function.
127
132
 
128
133
  Returns
129
134
  -------
130
135
  str
131
- One of: 'balance', 'diversity', 'sufficiency', 'stats', 'drift_mvdc', 'feasibility'
136
+ One of: 'balance', 'diversity', 'sufficiency', 'stats', 'drift_mvdc', 'drift', 'feasibility'
132
137
  """
133
138
  ...
134
139
 
@@ -289,6 +294,38 @@ class PlottableDriftMVDC(Plottable, Protocol):
289
294
  def plot_type(self) -> Literal["drift_mvdc"]: ...
290
295
 
291
296
 
297
+ @runtime_checkable
298
+ class PlottableDrift(Plottable, Protocol):
299
+ """Protocol for chunked drift detector outputs.
300
+
301
+ Required attributes:
302
+ - details: Per-chunk results as a polars DataFrame
303
+ - metric_name: Name of the drift metric that each chunk reports
304
+ - plot_type -> 'drift'
305
+ """
306
+
307
+ @property
308
+ def details(self) -> Any:
309
+ """Per-chunk drift results.
310
+
311
+ Returns
312
+ -------
313
+ pl.DataFrame
314
+ One row per chunk with the columns ``index``, ``value``, ``lower_threshold``, ``upper_threshold`` and
315
+ ``drifted``. A detector that was not chunked holds a mapping of statistics instead, which cannot be
316
+ plotted.
317
+ """
318
+ ...
319
+
320
+ @property
321
+ def metric_name(self) -> str:
322
+ """Name of the metric that ``value`` reports, such as ``auroc`` or ``mmd2``."""
323
+ ...
324
+
325
+ @property
326
+ def plot_type(self) -> Literal["drift"]: ...
327
+
328
+
292
329
  class FeasibilityEndpoint(Protocol):
293
330
  """
294
331
  One bound on one target, as DataEval's ``Endpoint`` records it; only the fields the plot reads.
@@ -323,8 +360,8 @@ class FeasibilityEvidence(Protocol):
323
360
 
324
361
 
325
362
  @runtime_checkable
326
- class PlottableFeasibility(Plottable, Protocol):
327
- """Protocol for feasibility plot outputs.
363
+ class PlottableFeasibilityDeterminations(Plottable, Protocol):
364
+ """Protocol for feasibility plot outputs in the ``determinations`` and ``evidence`` shape.
328
365
 
329
366
  Required attributes:
330
367
  - determinations: One row per target (DataFrame)
@@ -347,6 +384,45 @@ class PlottableFeasibility(Plottable, Protocol):
347
384
  def plot_type(self) -> Literal["feasibility"]: ...
348
385
 
349
386
 
387
+ @runtime_checkable
388
+ class PlottableFeasibilityDecisions(Plottable, Protocol):
389
+ """Protocol for feasibility plot outputs in the ``decisions`` and ``bounds`` shape.
390
+
391
+ Required attributes:
392
+ - decisions: One row per requirement (DataFrame)
393
+ - bounds: One row per source, requirement and quantity (DataFrame)
394
+ - meta().state["alpha"]: The call's error budget
395
+ - plot_type -> 'feasibility'
396
+
397
+ Structures:
398
+ - decisions columns: requirement, metric, threshold, judged_scope, class_name, aggregation, ap_functional,
399
+ max_false_positive_rate, decision, limitation, envelope, envelope_scope, witness, witness_source
400
+ - bounds columns: requirement, source, role, quantity, bound_type, information_scope, unit, state, bound,
401
+ observed, n; ``requirement`` joins a bound to its decisions row
402
+
403
+ Enum columns are read as strings, with these values:
404
+ - decision: demonstrated_feasible, infeasible_under_recorded_assumptions, not_ruled_out, unresolved
405
+ - limitation: only ``conflict`` is read; with decision ``unresolved`` it marks a conflict between a witness and an
406
+ envelope
407
+ - judged_scope, envelope_scope, information_scope: full_input, fixed_representation, partition_restricted
408
+ - source, witness_source: local_panels, replicas, knn, linear_probe, user_model (any other value shows its text)
409
+ - role: envelope, witness. bound_type: lower, upper
410
+ - quantity: a metric name (accuracy, ap, recall, recall_at_fp, mota) or false_positive_rate
411
+ - state: unavailable, uninformative, informative. unit: image, sequence
412
+ - aggregation: frame_pooled, sequence_mean. ap_functional: population_ap_integral, population_ap_101
413
+ """
414
+
415
+ @property
416
+ def decisions(self) -> pl.DataFrame: ...
417
+ @property
418
+ def bounds(self) -> pl.DataFrame: ...
419
+ @property
420
+ def plot_type(self) -> Literal["feasibility"]: ...
421
+
422
+
423
+ PlottableFeasibility = PlottableFeasibilityDeterminations | PlottableFeasibilityDecisions
424
+ """A feasibility output in either shape; DataEval 1.2.0 gives the first, later releases the second."""
425
+
350
426
  # Type alias for all plottable types
351
427
  PlottableType = (
352
428
  Dataset
@@ -355,5 +431,6 @@ PlottableType = (
355
431
  | PlottableSufficiency
356
432
  | PlottableStats
357
433
  | PlottableDriftMVDC
434
+ | PlottableDrift
358
435
  | PlottableFeasibility
359
436
  )