dataeval-plots 0.0.14__tar.gz → 0.0.16__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.14 → dataeval_plots-0.0.16}/PKG-INFO +8 -2
  2. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/README.md +7 -1
  3. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/__init__.py +10 -0
  4. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/_version.py +2 -2
  5. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/_altair.py +78 -0
  6. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/_base.py +20 -0
  7. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/_matplotlib.py +32 -0
  8. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/_plotly.py +109 -0
  9. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/_seaborn.py +44 -0
  10. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/_shared.py +274 -23
  11. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/protocols.py +44 -3
  12. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/.gitignore +0 -0
  13. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/pyproject.toml +0 -0
  14. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/_registry.py +0 -0
  15. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/__init__.py +0 -0
  16. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/src/dataeval_plots/backends/_feasibility_layout.py +0 -0
  17. {dataeval_plots-0.0.14 → dataeval_plots-0.0.16}/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.14
3
+ Version: 0.0.16
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/
@@ -238,7 +238,13 @@ up, so rounding never overstates what the evidence shows. Requirements and confi
238
238
  "95.5%" or "at 97.5% joint confidence".
239
239
 
240
240
  When a ceiling bounds the band, the caption ends with the footnote "\* Ruled out under the recorded assumptions, at
241
- the stated confidence." A group-based ceiling adds "(group-based ceiling; validity pending lead-scientist sign-off)".
241
+ the stated confidence."
242
+
243
+ A recall requirement is titled "Required recall for Tank (at most 5% false positives)". Only a witness whose own upper
244
+ confidence bound on false positives is within that limit counts; the caption adds its bound after the observed
245
+ sentence, for example "Its upper confidence bound on false positives is 4.1% (observed 2.6% of 651 other images)." In
246
+ the evidence view, a witness over the limit keeps its recall bound and observed score but is drawn faint, its label
247
+ saying why it is not counted, for example "66.0% lower bound · not counted: false positives ≤8.3% (limit 5%)".
242
248
 
243
249
  A ceiling that applies only to narrower models than the band judges never draws a zone. It appears as a note instead,
244
250
  for example "Group-based ceiling (72.1%) applies only to models that give one answer per group."
@@ -192,7 +192,13 @@ up, so rounding never overstates what the evidence shows. Requirements and confi
192
192
  "95.5%" or "at 97.5% joint confidence".
193
193
 
194
194
  When a ceiling bounds the band, the caption ends with the footnote "\* Ruled out under the recorded assumptions, at
195
- the stated confidence." A group-based ceiling adds "(group-based ceiling; validity pending lead-scientist sign-off)".
195
+ the stated confidence."
196
+
197
+ A recall requirement is titled "Required recall for Tank (at most 5% false positives)". Only a witness whose own upper
198
+ confidence bound on false positives is within that limit counts; the caption adds its bound after the observed
199
+ sentence, for example "Its upper confidence bound on false positives is 4.1% (observed 2.6% of 651 other images)." In
200
+ the evidence view, a witness over the limit keeps its recall bound and observed score but is drawn faint, its label
201
+ saying why it is not counted, for example "66.0% lower bound · not counted: false positives ≤8.3% (limit 5%)".
196
202
 
197
203
  A ceiling that applies only to narrower models than the band judges never draws a zone. It appears as a note instead,
198
204
  for example "Group-based ceiling (72.1%) applies only to models that give one answer per group."
@@ -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.14'
22
- __version_tuple__ = version_tuple = (0, 0, 14)
21
+ __version__ = version = '0.0.16'
22
+ __version_tuple__ = version_tuple = (0, 0, 16)
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,
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
19
19
  from dataeval_plots.protocols import (
20
20
  PlottableBalance,
21
21
  PlottableDiversity,
22
+ PlottableDrift,
22
23
  PlottableDriftMVDC,
23
24
  PlottableFeasibility,
24
25
  )
@@ -46,6 +47,10 @@ __all__ = [
46
47
  "prepare_diversity_data",
47
48
  "prepare_drift_data",
48
49
  "plot_drift_on_axis",
50
+ "CHUNKED_DRIFT_COLUMNS",
51
+ "ChunkedDriftData",
52
+ "prepare_chunked_drift",
53
+ "plot_chunked_drift_on_axis",
49
54
  "FEASIBILITY_COLORS",
50
55
  "FEASIBILITY_CONFLICT_TEXT",
51
56
  "FeasibilityBand",
@@ -498,6 +503,200 @@ def prepare_drift_data(
498
503
  return resdf, trndf, tstdf, driftx, True
499
504
 
500
505
 
506
+ CHUNKED_DRIFT_COLUMNS = ("index", "value", "lower_threshold", "upper_threshold", "drifted")
507
+
508
+
509
+ @dataclass(frozen=True)
510
+ class ChunkedDriftData:
511
+ """
512
+ The per-chunk results of a chunked drift detector, ready for plotting.
513
+
514
+ Attributes
515
+ ----------
516
+ frame : pl.DataFrame
517
+ One row per chunk with the columns ``index``, ``value``, ``lower_threshold``, ``upper_threshold``,
518
+ ``drifted`` and ``period``. The rows of the reference chunks come first, numbered from ``-n`` to ``-1``
519
+ so that the analysis chunks keep their own indexes, and have ``period`` set to ``"reference"``. The
520
+ analysis chunks have ``period`` set to ``"analysis"``.
521
+ metric_name : str
522
+ Name of the metric that ``value`` reports
523
+ chunk_label : str
524
+ Label for the axis of chunks, which includes the size of a chunk when every analysis chunk has the same size
525
+ """
526
+
527
+ frame: pl.DataFrame
528
+ metric_name: str
529
+ chunk_label: str
530
+
531
+ @property
532
+ def reference(self) -> pl.DataFrame:
533
+ """The rows of the reference chunks, which is empty when the output carries no baseline."""
534
+ return self.frame.filter(pl.col("period") == "reference")
535
+
536
+ @property
537
+ def analysis(self) -> pl.DataFrame:
538
+ """The rows of the analysis chunks."""
539
+ return self.frame.filter(pl.col("period") == "analysis")
540
+
541
+
542
+ def prepare_chunked_drift(output: PlottableDrift) -> ChunkedDriftData:
543
+ """
544
+ Read the per-chunk table, the reference chunks and the metric name from a chunked drift output.
545
+
546
+ The reference chunks are the optional ``baseline`` attribute of the output, which holds the metric of each
547
+ reference chunk. An output without it, such as one from an earlier version of DataEval, has no reference rows
548
+ and the plot leaves them out.
549
+
550
+ Parameters
551
+ ----------
552
+ output : PlottableDrift
553
+ The drift output object to plot
554
+
555
+ Returns
556
+ -------
557
+ ChunkedDriftData
558
+ The per-chunk results. A threshold that is missing, as for a one-sided detector, is replaced by a bound just
559
+ beyond the data, so that the range of no drift has no limit on that side.
560
+
561
+ Raises
562
+ ------
563
+ ValueError
564
+ If the output holds the statistics of a single test and not a per-chunk table, or if the table lacks a column
565
+ that the plot needs
566
+ """
567
+ details = output.details
568
+ if not isinstance(details, pl.DataFrame):
569
+ raise ValueError("Plotting drift needs chunked results. Call .chunked() on the detector before predict().")
570
+ missing = [name for name in CHUNKED_DRIFT_COLUMNS if name not in details.columns]
571
+ if missing:
572
+ raise ValueError(f"The per-chunk drift results lack the columns {missing}.")
573
+
574
+ analysis = details.select(
575
+ pl.col("index").cast(pl.Int64),
576
+ pl.col("value", "lower_threshold", "upper_threshold").cast(pl.Float64),
577
+ pl.col("drifted").cast(pl.Boolean),
578
+ pl.lit("analysis").alias("period"),
579
+ )
580
+
581
+ # The thresholds come from the reference chunks, so the band spans them too
582
+ baseline = np.asarray(getattr(output, "baseline", None) or (), dtype=np.float64)
583
+ if len(baseline) and len(analysis):
584
+ reference = pl.DataFrame(
585
+ {
586
+ "index": np.arange(-len(baseline), 0, dtype=np.int64),
587
+ "value": baseline,
588
+ "lower_threshold": [analysis["lower_threshold"][0]] * len(baseline),
589
+ "upper_threshold": [analysis["upper_threshold"][0]] * len(baseline),
590
+ "drifted": [False] * len(baseline),
591
+ "period": ["reference"] * len(baseline),
592
+ },
593
+ schema=analysis.schema,
594
+ )
595
+ frame = pl.concat([reference, analysis])
596
+ else:
597
+ frame = analysis
598
+
599
+ if frame["lower_threshold"].null_count() or frame["upper_threshold"].null_count():
600
+ extent = pl.concat([frame["value"], frame["lower_threshold"], frame["upper_threshold"]]).drop_nulls()
601
+ low, high = extent.min(), extent.max()
602
+ pad = 0.1 * (high - low) if low != high else 1.0 # type: ignore[operator]
603
+ frame = frame.with_columns(
604
+ pl.col("lower_threshold").fill_null(low - pad), # type: ignore[operator]
605
+ pl.col("upper_threshold").fill_null(high + pad), # type: ignore[operator]
606
+ )
607
+
608
+ return ChunkedDriftData(frame, output.metric_name, _chunk_label(details))
609
+
610
+
611
+ def _chunk_label(details: pl.DataFrame) -> str:
612
+ """Label the axis of chunks with the size of a chunk, when every chunk has the same size."""
613
+ if "start_index" in details.columns and "end_index" in details.columns and len(details):
614
+ sizes = (details["end_index"] - details["start_index"] + 1).unique()
615
+ if len(sizes) == 1 and (details["start_index"] >= 0).all():
616
+ return f"Chunk ({sizes[0]} samples each)"
617
+ return "Chunk"
618
+
619
+
620
+ def plot_chunked_drift_on_axis(
621
+ ax: Any,
622
+ data: ChunkedDriftData,
623
+ line_color: Any = "tab:blue",
624
+ band_color: Any = "tab:green",
625
+ drift_color: Any = "tab:red",
626
+ reference_color: Any = "tab:gray",
627
+ linewidth: float = 1.5,
628
+ ) -> None:
629
+ """
630
+ Plot the metric of each chunk against the threshold range of the detector on a matplotlib axis.
631
+
632
+ This is a shared helper for the matplotlib and seaborn backends. The metric is a line with a marker per chunk,
633
+ the band is the range between the lower and upper thresholds in which a chunk counts as no drift, and a diamond
634
+ marks each chunk that the detector flagged. When the output carries the metric of the reference chunks, they are
635
+ drawn in a second color to the left of the analysis chunks, with a dotted line between them.
636
+
637
+ Parameters
638
+ ----------
639
+ ax : matplotlib.axes.Axes
640
+ Axis to plot on
641
+ data : ChunkedDriftData
642
+ Per-chunk results from :func:`prepare_chunked_drift`
643
+ line_color : Any, default "tab:blue"
644
+ Color of the metric line of the analysis chunks
645
+ band_color : Any, default "tab:green"
646
+ Color of the band between the thresholds
647
+ drift_color : Any, default "tab:red"
648
+ Color of the markers on the chunks with drift
649
+ reference_color : Any, default "tab:gray"
650
+ Color of the metric line of the reference chunks and of the line that separates them
651
+ linewidth : float, default 1.5
652
+ Width of the metric lines
653
+ """
654
+ from matplotlib.ticker import MaxNLocator
655
+
656
+ frame, reference, analysis = data.frame, data.reference, data.analysis
657
+ ax.fill_between(
658
+ frame["index"].to_numpy(),
659
+ frame["lower_threshold"].to_numpy(),
660
+ frame["upper_threshold"].to_numpy(),
661
+ step="mid",
662
+ color=band_color,
663
+ alpha=0.15,
664
+ label="No-drift range",
665
+ )
666
+ if len(reference):
667
+ ax.plot(
668
+ reference["index"].to_numpy(),
669
+ reference["value"].to_numpy(),
670
+ marker="o",
671
+ color=reference_color,
672
+ linewidth=linewidth,
673
+ label="Reference chunks",
674
+ )
675
+ ax.axvline(-0.5, color=reference_color, linestyle=":", linewidth=1)
676
+ ax.plot(
677
+ analysis["index"].to_numpy(),
678
+ analysis["value"].to_numpy(),
679
+ marker="o",
680
+ color=line_color,
681
+ linewidth=linewidth,
682
+ label="Analysis chunks",
683
+ )
684
+ drifted = analysis.filter(pl.col("drifted"))
685
+ if len(drifted):
686
+ ax.scatter(
687
+ drifted["index"].to_numpy(),
688
+ drifted["value"].to_numpy(),
689
+ marker="D",
690
+ color=drift_color,
691
+ zorder=3,
692
+ label="Drift",
693
+ )
694
+ ax.xaxis.set_major_locator(MaxNLocator(integer=True))
695
+ ax.set_xlabel(data.chunk_label)
696
+ ax.set_ylabel(data.metric_name)
697
+ ax.legend(loc="best")
698
+
699
+
501
700
  def plot_drift_on_axis(
502
701
  ax: Any,
503
702
  resdf: Any,
@@ -697,6 +896,7 @@ _RULED_OUT = "ruled out*"
697
896
  _AGGREGATION_WORDS = {None: "", "sequence_mean": "per-sequence mean ", "frame_pooled": "frame-pooled "}
698
897
  _GROUP_CEILING = "group-based ceiling"
699
898
  _NO_BOUND = "no bound available"
899
+ _FALSE_POSITIVE = "false_positive_rate"
700
900
 
701
901
 
702
902
  def _pct(value: float, direction: str | None = None) -> str:
@@ -712,6 +912,11 @@ def _pct(value: float, direction: str | None = None) -> str:
712
912
  return f"{tenths / 10:.1f}%"
713
913
 
714
914
 
915
+ def _confidence(value: float, direction: str) -> str:
916
+ """Round a confidence down or an error share up to 0.1%, dropping a trailing ".0", as the printout does."""
917
+ return _pct(value, direction).replace(".0%", "%")
918
+
919
+
715
920
  def _percent(value: float) -> str:
716
921
  """A threshold, confidence or alpha as DataEval prints it: no rounding to whole percents, no trailing zeros."""
717
922
  return f"{value * 100:g}%"
@@ -769,6 +974,16 @@ def _feasibility_range(values: Iterable[float]) -> tuple[float, float]:
769
974
  return round(max(low, 0.0), 2), 1.0
770
975
 
771
976
 
977
+ def _not_counted(e: dict[str, Any]) -> str:
978
+ """The label suffix of a recall witness that does not count: its false-positive bound is over the limit or none."""
979
+ if not e["uncertified"]:
980
+ return ""
981
+ fp = e["false_positive_bound"]
982
+ seen = "no false-positive bound" if fp is None else f"false positives ≤{_pct(fp, 'upper')}"
983
+ limit = "" if fp is None else f" (limit {_percent(e['false_positive_limit'])})"
984
+ return f" · not counted: {seen}{limit}"
985
+
986
+
772
987
  def _feasibility_evidence(
773
988
  evidence: list[dict[str, Any]], scope: str, unit: str
774
989
  ) -> tuple[list[FeasibilityEvidenceRow], list[str]]:
@@ -802,10 +1017,10 @@ def _feasibility_evidence(
802
1017
  observed=observed,
803
1018
  restricted=restricted,
804
1019
  label=label,
805
- bound_label=_NO_BOUND if bound is None else f"{_pct(bound, kind)} {kind} bound",
1020
+ bound_label=_NO_BOUND if bound is None else f"{_pct(bound, kind)} {kind} bound" + _not_counted(e),
806
1021
  observed_label=None if observed is None else f"{_pct(observed)} observed",
807
1022
  bound_left=kind == "lower",
808
- faint=restricted or bound is None,
1023
+ faint=restricted or bound is None or e["uncertified"],
809
1024
  )
810
1025
  )
811
1026
  if restricted and kind == "upper" and bound is not None:
@@ -849,18 +1064,29 @@ def _unit(rows: list[dict[str, Any]], evidence: list[dict[str, Any]]) -> str | N
849
1064
  return None
850
1065
 
851
1066
 
1067
+ def _other(fp: dict[str, Any]) -> str:
1068
+ """The audit negatives a false-positive bound was measured on, such as ``651 other images``."""
1069
+ return ("" if fp["n"] is None else f"{fp['n']:,} ") + f"other {fp['unit']}s"
1070
+
1071
+
852
1072
  def _feasibility_title(spec: dict[str, Any]) -> str:
853
1073
  """The axis title, naming the AP class (or macro) and any aggregation so per-class bands differ."""
854
1074
  metric = spec["metric"]
855
1075
  if metric == "ap":
856
1076
  metric = "macro AP" if spec.get("class_name") is None else f"AP for class {spec['class_name']}"
1077
+ elif metric == "recall":
1078
+ f0 = _percent(spec["max_false_positive_rate"])
1079
+ metric = f"recall for {spec['class_name']} (at most {f0} false positives)"
857
1080
  return "Required " + _AGGREGATION_WORDS[spec.get("aggregation")] + metric
858
1081
 
859
1082
 
860
1083
  def _feasibility_band(
861
- rows: list[dict[str, Any]], evidence: list[dict[str, Any]], alpha: float, noun: str
1084
+ rows: list[dict[str, Any]], all_evidence: list[dict[str, Any]], alpha: float, noun: str
862
1085
  ) -> FeasibilityBand:
863
1086
  """One band; ``noun`` is the unit the printout's footer names for the whole call."""
1087
+ # Bands and evidence rows draw the metric; a recall witness's false-positive bound only joins the caption
1088
+ evidence = [e for e in all_evidence if e["quantity"] == "metric"]
1089
+ false_positives = [e for e in all_evidence if e["quantity"] == _FALSE_POSITIVE]
864
1090
  scope = rows[0]["judged_scope"]
865
1091
  unit = _unit(rows, evidence) or "image"
866
1092
  # A lower bound of 0% demonstrates nothing and an upper bound of 100% rules nothing out
@@ -887,7 +1113,7 @@ def _feasibility_band(
887
1113
 
888
1114
  conflict_caption = (
889
1115
  f"Bounds disagree: check the assumptions, such as evaluation {unit}s entering training. "
890
- f"Even when they hold, the chance that any bound fails is at most {_percent(alpha)}."
1116
+ f"Even when they hold, the chance that any bound fails is at most {_confidence(alpha, 'upper')}."
891
1117
  )
892
1118
  if conflict:
893
1119
  caption = [conflict_caption]
@@ -896,25 +1122,38 @@ def _feasibility_band(
896
1122
  witness = None if best is None else _deciding(best, evidence)[0]
897
1123
  if witness and witness["observed"] is not None and w is not None:
898
1124
  on = "the " if witness["n"] is None else f"{witness['n']:,} "
1125
+ # A recall witness's count is the class's positives, which the printout names by class
1126
+ spec = json.loads(rows[0]["target_spec"])
1127
+ scored = f"{_pct(witness['observed'])} on {on}evaluation {unit}s"
1128
+ if spec["metric"] == "recall":
1129
+ scored = f"{_pct(witness['observed'])} recall on {on}{spec['class_name']} {unit}s"
899
1130
  caption.append(
900
- f"{_sentence_case(witness['source'])} scored {_pct(witness['observed'])} on {on}evaluation "
901
- f"{unit}s; given sampling uncertainty, its lower confidence bound is {_pct(w, 'lower')}."
1131
+ f"{_sentence_case(witness['source'])} scored {scored}; given sampling uncertainty, its lower "
1132
+ f"confidence bound is {_pct(w, 'lower')}."
902
1133
  )
903
- caption.append(
904
- f"Bounds apply to new {noun}s from the same population, at {_percent(1 - alpha)} joint confidence."
905
- )
1134
+ fp = None
1135
+ if witness and w is not None:
1136
+ fp = next(
1137
+ (
1138
+ e
1139
+ for e in false_positives
1140
+ if e["evidence_id"] == witness["evidence_id"]
1141
+ and e["target_spec"] == witness["target_spec"]
1142
+ and e["bound"] is not None
1143
+ ),
1144
+ None,
1145
+ )
1146
+ if fp is not None:
1147
+ seen = "" if fp["observed"] is None else f" (observed {_pct(fp['observed'])} of {_other(fp)})"
1148
+ caption.append(f"Its upper confidence bound on false positives is {_pct(fp['bound'], 'upper')}{seen}.")
1149
+ joint = _confidence(1 - alpha, "lower")
1150
+ caption.append(f"Bounds apply to new {noun}s from the same population, at {joint} joint confidence.")
906
1151
  # The "*" footnote to every "ruled out*" goes last, as a footnote does
907
1152
  if deciding is not None and c is not None:
908
- ceiling = _deciding(deciding, evidence)[1]
909
- sign_off = ceiling is not None and ceiling["source"] == _GROUP_CEILING
910
- caption.append(
911
- "* Ruled out under the recorded assumptions, at the stated confidence"
912
- + (" (group-based ceiling; validity pending lead-scientist sign-off)" if sign_off else "")
913
- + "."
914
- )
1153
+ caption.append("* Ruled out under the recorded assumptions, at the stated confidence.")
915
1154
  evidence_caption = [
916
1155
  f"Observed on the evaluation {unit}s; bounds apply to new {unit}s from the same population, "
917
- f"at {_percent(1 - alpha)} joint confidence."
1156
+ f"at {_confidence(1 - alpha, 'lower')} joint confidence."
918
1157
  ]
919
1158
  if conflict:
920
1159
  evidence_caption.append(conflict_caption)
@@ -962,6 +1201,10 @@ _EVIDENCE_SCHEMA = {
962
1201
  "bound": pl.Float64,
963
1202
  "n": pl.Int64,
964
1203
  "state": pl.String,
1204
+ "quantity": pl.String,
1205
+ "uncertified": pl.Boolean,
1206
+ "false_positive_bound": pl.Float64,
1207
+ "false_positive_limit": pl.Float64,
965
1208
  }
966
1209
 
967
1210
 
@@ -971,10 +1214,14 @@ def _evidence_rows(output: PlottableFeasibility) -> pl.DataFrame:
971
1214
  rows = []
972
1215
  for spec, target in targets.items():
973
1216
  for eid, ev in output.evidence.items():
974
- for e in ev.endpoints:
975
- if e.target_spec != spec:
976
- continue
977
- stats = ev.details["endpoints"][spec] or {}
1217
+ # An older DataEval has no quantity; its endpoints all bound the metric
1218
+ mine = [(e, getattr(e, "quantity", "metric")) for e in ev.endpoints if e.target_spec == spec]
1219
+ fp = next((e.value for e, q in mine if q == _FALSE_POSITIVE), None)
1220
+ f0 = json.loads(spec).get("max_false_positive_rate")
1221
+ for e, q in mine:
1222
+ stats = ev.details["endpoints"].get(spec if q == "metric" else f"{spec}#{q}") or {}
1223
+ # A recall witness counts only when its own false-positive upper bound is within the limit
1224
+ uncertified = q == "metric" and e.direction == "lower" and f0 is not None and (fp is None or fp > f0)
978
1225
  rows.append(
979
1226
  {
980
1227
  "target_spec": spec,
@@ -988,10 +1235,14 @@ def _evidence_rows(output: PlottableFeasibility) -> pl.DataFrame:
988
1235
  "unit": "sequence" if ev.unit == "group" else "image",
989
1236
  "bound_type": e.direction,
990
1237
  # A ceiling's mean is panel agreement, not an observed score
991
- "observed": stats.get("mean") if e.direction == "lower" else None,
1238
+ "observed": stats.get("mean") if e.direction == "lower" or q == _FALSE_POSITIVE else None,
992
1239
  "bound": e.value,
993
1240
  "n": stats.get("n"),
994
1241
  "state": e.state,
1242
+ "quantity": q,
1243
+ "uncertified": uncertified,
1244
+ "false_positive_bound": fp,
1245
+ "false_positive_limit": f0,
995
1246
  }
996
1247
  )
997
1248
  return pl.DataFrame(rows, schema=_EVIDENCE_SCHEMA)
@@ -1022,7 +1273,7 @@ def prepare_feasibility_data(output: PlottableFeasibility) -> list[FeasibilityBa
1022
1273
  groups.setdefault(json.dumps(spec, sort_keys=True), []).append(row)
1023
1274
  evidence = _evidence_rows(output).to_dicts()
1024
1275
  alpha = output.meta().state["alpha"]
1025
- noun = _unit(rows, evidence) or "image" # the printout's footer noun, for the whole call
1276
+ noun = _unit(rows, [e for e in evidence if e["quantity"] == "metric"]) or "image" # the printout's footer noun
1026
1277
  bands = []
1027
1278
  for band in groups.values():
1028
1279
  specs = {r["target_spec"] for r in band}
@@ -12,6 +12,7 @@ The protocol hierarchy:
12
12
  - PlottableSufficiency
13
13
  - PlottableStats
14
14
  - PlottableDriftMVDC
15
+ - PlottableDrift
15
16
  - PlottableFeasibility
16
17
  """
17
18
 
@@ -122,13 +123,15 @@ class Plottable(Protocol):
122
123
  """
123
124
 
124
125
  @property
125
- def plot_type(self) -> Literal["balance", "diversity", "sufficiency", "stats", "drift_mvdc", "feasibility"]:
126
+ def plot_type(
127
+ self,
128
+ ) -> Literal["balance", "diversity", "sufficiency", "stats", "drift_mvdc", "drift", "feasibility"]:
126
129
  """Return the plot type identifier for routing to appropriate plot function.
127
130
 
128
131
  Returns
129
132
  -------
130
133
  str
131
- One of: 'balance', 'diversity', 'sufficiency', 'stats', 'drift_mvdc', 'feasibility'
134
+ One of: 'balance', 'diversity', 'sufficiency', 'stats', 'drift_mvdc', 'drift', 'feasibility'
132
135
  """
133
136
  ...
134
137
 
@@ -289,8 +292,45 @@ class PlottableDriftMVDC(Plottable, Protocol):
289
292
  def plot_type(self) -> Literal["drift_mvdc"]: ...
290
293
 
291
294
 
295
+ @runtime_checkable
296
+ class PlottableDrift(Plottable, Protocol):
297
+ """Protocol for chunked drift detector outputs.
298
+
299
+ Required attributes:
300
+ - details: Per-chunk results as a polars DataFrame
301
+ - metric_name: Name of the drift metric that each chunk reports
302
+ - plot_type -> 'drift'
303
+ """
304
+
305
+ @property
306
+ def details(self) -> Any:
307
+ """Per-chunk drift results.
308
+
309
+ Returns
310
+ -------
311
+ pl.DataFrame
312
+ One row per chunk with the columns ``index``, ``value``, ``lower_threshold``, ``upper_threshold`` and
313
+ ``drifted``. A detector that was not chunked holds a mapping of statistics instead, which cannot be
314
+ plotted.
315
+ """
316
+ ...
317
+
318
+ @property
319
+ def metric_name(self) -> str:
320
+ """Name of the metric that ``value`` reports, such as ``auroc`` or ``mmd2``."""
321
+ ...
322
+
323
+ @property
324
+ def plot_type(self) -> Literal["drift"]: ...
325
+
326
+
292
327
  class FeasibilityEndpoint(Protocol):
293
- """One bound on one target, as DataEval's ``Endpoint`` records it; only the fields the plot reads."""
328
+ """
329
+ One bound on one target, as DataEval's ``Endpoint`` records it; only the fields the plot reads.
330
+
331
+ Newer outputs also give ``quantity``, ``"metric"`` or ``"false_positive_rate"``; the plot reads it with a default
332
+ of ``"metric"`` so older outputs still plot.
333
+ """
294
334
 
295
335
  @property
296
336
  def target_spec(self) -> str: ...
@@ -350,5 +390,6 @@ PlottableType = (
350
390
  | PlottableSufficiency
351
391
  | PlottableStats
352
392
  | PlottableDriftMVDC
393
+ | PlottableDrift
353
394
  | PlottableFeasibility
354
395
  )