{ "cells": [ { "cell_type": "markdown", "id": "45285a4c", "metadata": {}, "source": [ "# 04. PySATL CPD API: Analysis\n", "\n", "## Section 1. Orientation\n", "\n", "Once a provider and a trace exist, the next practical problem is understanding them quickly. Raw arrays and tables are useful, but online detection work becomes much easier when ground truth, detector output, timing, and state evolution can be seen rather than inferred. The visualization layer exists to make that interpretation step explicit and reusable.\n", "\n", "This notebook is intentionally example-heavy. The old visualization tutorial contained several concrete plotting patterns that were useful even though the code paths behind them became obsolete. Here we keep those example ideas, port them to the current API, and place matplotlib and plotly variants side by side whenever the visualization goal is the same. The notebook therefore works both as a teaching chapter and as a gallery of reusable plotting recipes.\n", "\n", "\n", "Several examples now also show detector learning periods and skip periods, because those intervals often explain why a trace does or does not fire near a true change point.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import plotly.io as pio\n", "pio.renderers.default = 'sphinx_gallery'\n" ] }, { "cell_type": "markdown", "id": "767bfe4c", "metadata": {}, "source": [ "## What in this notebook\n", "\n", "By the end of the notebook, you should know how to:\n", "\n", "- draw a labeled time series manually, with true change points and margin windows,\n", "- render the same information through reusable visual components,\n", "- use `UnivariateTimeseriesVisualizer`, `OnlineTraceVisualizer`, and `OnlineCpdPlotter`,\n", "- switch between matplotlib and plotly while keeping the same conceptual plot layout,\n", "- visualize algorithm state evolution with a Shewhart-specific state visualizer,\n", "- and build benchmark-style figures from metric tables using `BenchmarkPlotter`.\n", "\n", "Some examples use synthetic plotting tables purely to demonstrate the plotting layer. Others run a compact real benchmark flow so the benchmark visualization code is exercised on actual detector outputs.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "e3457285", "metadata": {}, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import pandas as pd\n", "import plotly.graph_objects as go\n", "from IPython.display import display\n", "from plotly.subplots import make_subplots\n", "\n", "from pysatl_cpd.algorithms.online import ShewhartControlChart, ShewhartControlChartState\n", "from pysatl_cpd.algorithms.online.control_charts.visualizers.state_shewhart_chart_visualizer import (\n", " ShewhartStateVisualizer,\n", ")\n", "from pysatl_cpd.analysis.metrics.multiple_run.classification import ClassificationReport\n", "from pysatl_cpd.analysis.metrics.multiple_run.online import ARLMetric, MeanDelayMetric, MedianDelayMetric\n", "from pysatl_cpd.analysis.metrics.single_run.classification import (\n", " FalseNegativeCount,\n", " FalsePositiveCount,\n", " TruePositiveCount,\n", ")\n", "from pysatl_cpd.analysis.metrics.single_run.online import Delays, RunLengths\n", "from pysatl_cpd.analysis.visualization import (\n", " DrawBackend,\n", " OnlineCpdPlotter,\n", " OnlineTraceVisualizer,\n", " PlainMultivariateTimeseriesVisualizer,\n", " RichMultivariateTimeseriesVisualizer,\n", " UnivariateTimeseriesVisualizer,\n", ")\n", "from pysatl_cpd.analysis.visualization.components import VerticalFillComponent, VerticalLineVisualComponent\n", "from pysatl_cpd.analysis.visualization.online.states import DummyStateVisualizer\n", "from pysatl_cpd.core.online import OnlineResetDetector\n", "from pysatl_cpd.core.single_run import SingleRun\n", "from pysatl_cpd.data.generator import (\n", " GenericSeriesGenerator,\n", " IndependentColumnsSpec,\n", " NormalSpec,\n", " ScenarioSpec,\n", " SegmentPlan,\n", " SegmentSpec,\n", " build_pandas_labeled_data,\n", " build_plain_multivariate_labeled_data,\n", " build_plain_univariate_labeled_data,\n", ")\n", "from pysatl_cpd.data.typedefs import StateDescriptor, frozendict" ] }, { "cell_type": "markdown", "id": "8daceec7", "metadata": {}, "source": [ "## Section 2. Preparing one common provider and trace\n", "\n", "The legacy visualization tutorial mixed several data-generation styles. For a modern API tutorial, it is cleaner to prepare one reusable provider and one reusable trace up front, then use them across the plotting examples. That keeps the reader focused on plotting differences rather than on data setup changes.\n", "\n", "The provider below is a simple mean-shift series with one true change point, and the trace comes from a reset-style Shewhart run with collected algorithm states. That combination gives us enough material for manual plots, visualizer-based plots, and state-evolution plots.\n" ] }, { "cell_type": "markdown", "id": "f79b46f3", "metadata": {}, "source": [ "### 2.1 Building the provider\n", "\n", "The first problem is making sure the plotted example has obvious ground truth. We want a clear mean shift, one labeled change point, and enough length to show learning, detection, and post-detection behavior.\n", "\n", "The generator API gives us that source cleanly. We define one short univariate scenario, generate it reproducibly, and immediately convert it into a labeled provider that the visualization layer can consume.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "f8d433e3", "metadata": {}, "outputs": [], "source": [ "visualization_scenario = ScenarioSpec(\n", " name=\"visualization_demo\",\n", " segments=(\n", " SegmentSpec(plan_name=\"baseline\", length=100),\n", " SegmentSpec(plan_name=\"shifted\", length=80),\n", " SegmentSpec(plan_name=\"baseline\", length=60),\n", " ),\n", " plans=frozendict(\n", " baseline=SegmentPlan(\n", " distribution=NormalSpec(mean=0.0, std=1.0),\n", " state=StateDescriptor(type=\"baseline\"),\n", " name=\"baseline\",\n", " ),\n", " shifted=SegmentPlan(\n", " distribution=NormalSpec(mean=3.0, std=1.0),\n", " state=StateDescriptor(type=\"shifted\"),\n", " name=\"shifted\",\n", " ),\n", " ),\n", ")\n", "visualization_series = GenericSeriesGenerator(seed=17).generate_from_scenario(\n", " visualization_scenario,\n", " name=\"visualization_series\",\n", ")\n", "visualization_provider = build_plain_univariate_labeled_data(\n", " visualization_series,\n", " feature_name=\"value\",\n", " name=\"visualization_provider\",\n", ")\n", "\n", "print(\"Provider length:\", len(visualization_provider))\n", "print(\"Ground-truth change points:\", list(visualization_provider.change_points))\n", "print(\"First 10 values:\", np.round(visualization_provider.raw_data[:10], 3).tolist())" ] }, { "cell_type": "markdown", "id": "f092ce1d", "metadata": {}, "source": [ "### 2.2 Building the trace\n", "\n", "The second problem is making sure the plotting examples have enough runtime metadata to be interesting. A plain provider is enough for manual time-series plots, but a trace is needed once we want to show detection statistics, processing times, or algorithm state evolution.\n", "\n", "A reset-style Shewhart detector with state collection enabled is a good default for this purpose. It produces a compact, interpretable trace while still exposing enough detail for the visualization layer to demonstrate its full surface.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "173b4383", "metadata": {}, "outputs": [], "source": [ "visualization_detector = OnlineResetDetector(\n", " ShewhartControlChart(learning_period_size=30, window_size=10),\n", " threshold=2.0,\n", " skip_period=8,\n", " collect_states=True,\n", ")\n", "visualization_trace = visualization_detector.detect(visualization_provider)\n", "\n", "print(\"Detected change points:\", visualization_trace.detected_change_points)\n", "print(\"Signal change points:\", visualization_trace.signal_change_points)\n", "print(\"Skip periods:\", visualization_trace.skip_periods)\n", "print(\"Learning periods:\", visualization_trace.learning_periods)\n", "\n", "manual_learning_periods = list(visualization_trace.learning_periods)\n", "manual_skip_periods = list(visualization_trace.skip_periods)" ] }, { "cell_type": "markdown", "id": "086a93f1", "metadata": {}, "source": [ "**Summary of Section 2:** We now have one provider and one trace that will anchor all later examples. The provider gives us ground truth, while the trace gives us the detector-side runtime information needed for richer plots.\n" ] }, { "cell_type": "markdown", "id": "87367588", "metadata": {}, "source": [ "## Section 3. Manual Visualization\n", "\n", "Before using reusable visualizers, it is worth solving the most basic plotting problem by hand. Manual plotting makes the visual ingredients explicit: the raw time series, the true change points, the acceptable margin around each change, and the detector\u2019s own numerical output.\n", "\n", "These examples are intentionally close in spirit to the old tutorial. They show what we are trying to communicate visually before we hand that communication job over to higher-level plotting abstractions.\n" ] }, { "cell_type": "markdown", "id": "46403fa3", "metadata": {}, "source": [ "### 3.1 Manual matplotlib timeseries plot\n", "\n", "The first manual visualization problem is the plainest one: how do we show the time series together with the true change point and a tolerance window around it? This is the minimum plot a reader needs before any detector output is introduced.\n", "\n", "In matplotlib, the API ingredients are just a line plot, one or more vertical lines, and one or more filled regions. The point of the example is not sophistication; it is visual clarity and explicitness.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "203f6d34", "metadata": {}, "outputs": [], "source": [ "manual_margin = 10\n", "fig, ax = plt.subplots(1, 1, figsize=(14, 4), sharex=True)\n", "fig.suptitle(\"Manual Visualization of a Labeled Time Series\", fontsize=16, fontweight=\"bold\")\n", "ax.plot(\n", " np.arange(len(visualization_provider)),\n", " np.array(list(visualization_provider)),\n", " \"k-\",\n", " linewidth=1.0,\n", " alpha=0.7,\n", " label=\"Time Series\",\n", ")\n", "for index, cp in enumerate(visualization_provider.change_points):\n", " ax.axvline(cp, color=\"red\", linestyle=\"--\", linewidth=2, alpha=0.8, label=\"Ground Truth\" if index == 0 else \"\")\n", " ax.axvspan(\n", " cp - manual_margin, cp + manual_margin, alpha=0.1, color=\"red\", label=\"Margin Window\" if index == 0 else \"\"\n", " )\n", "ax.set_title(\"Time Series with Known Change Points\")\n", "ax.set_xlabel(\"Time Index\")\n", "ax.set_ylabel(\"Value\")\n", "ax.grid(True, alpha=0.3)\n", "ax.legend(loc=\"upper left\")\n", "plt.tight_layout()\n", "fig.show()" ] }, { "cell_type": "markdown", "id": "da28eef7", "metadata": {}, "source": [ "### 3.2 Manual plotly timeseries plot\n", "\n", "The same conceptual problem is worth solving in plotly because the backend supports hovering, zooming, and interactive legends. Seeing the same visual logic in a second backend helps separate plotting concepts from plotting library syntax.\n", "\n", "The plotly version still uses the same ingredients: a line trace for the series, line shapes for true change points, and rectangle shapes for the margin windows. Only the backend-specific mechanics change.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "60c6ea97", "metadata": {}, "outputs": [], "source": [ "fig = go.Figure()\n", "fig.add_trace(\n", " go.Scatter(\n", " x=np.arange(len(visualization_provider)),\n", " y=np.array(list(visualization_provider)),\n", " mode=\"lines\",\n", " name=\"Time Series\",\n", " line={\"color\": \"black\", \"width\": 1.5},\n", " opacity=0.7,\n", " )\n", ")\n", "for cp in visualization_provider.change_points:\n", " fig.add_vrect(x0=cp - manual_margin, x1=cp + manual_margin, fillcolor=\"red\", opacity=0.1, line_width=0)\n", " fig.add_vline(x=cp, line_color=\"red\", line_dash=\"dash\", line_width=2)\n", "fig.update_layout(\n", " title=\"Manual Plotly Visualization of a Labeled Time Series\",\n", " xaxis_title=\"Time Index\",\n", " yaxis_title=\"Value\",\n", " height=350,\n", ")\n", "fig" ] }, { "cell_type": "markdown", "id": "c6fc9fa6", "metadata": {}, "source": [ "### 3.3 Manual matplotlib overview of series and detector output\n", "\n", "Once the raw labeled series is visible, the next problem is connecting it to the detector. A single plot of the time series is not enough if we want to explain why a detector fired or how close a threshold crossing came to the true transition.\n", "\n", "This matplotlib example keeps the manual style but adds the detector-side outputs. The stacked layout mirrors the old tutorial: observations at the top, detection function in the middle, and processing time at the bottom.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "54358040", "metadata": {}, "outputs": [], "source": [ "fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)\n", "fig.suptitle(\"Manual Matplotlib Visualization of Online Detection Outputs\", fontsize=16, fontweight=\"bold\")\n", "time_index = np.arange(len(visualization_provider))\n", "trace_index = np.arange(len(visualization_trace.detection_function))\n", "\n", "axes[0].plot(\n", " time_index, np.array(list(visualization_provider)), color=\"black\", linewidth=1.5, alpha=0.7, label=\"Time Series\"\n", ")\n", "axes[1].plot(\n", " trace_index, visualization_trace.detection_function, color=\"blue\", linewidth=1.0, label=\"Detection Function\"\n", ")\n", "axes[1].axhline(visualization_trace.threshold, color=\"red\", linestyle=\"--\", linewidth=2, label=\"Threshold\")\n", "axes[2].plot(trace_index, visualization_trace.processing_time, color=\"purple\", linewidth=1.0, label=\"Processing Time\")\n", "axes[2].fill_between(trace_index, 0, visualization_trace.processing_time, color=\"purple\", alpha=0.3)\n", "\n", "for index, cp in enumerate(visualization_provider.change_points):\n", " axes[0].axvspan(\n", " cp - manual_margin, cp + manual_margin, color=\"red\", alpha=0.1, label=\"Margin Window\" if index == 0 else None\n", " )\n", " axes[0].axvline(\n", " cp, color=\"red\", linestyle=\"--\", linewidth=2, alpha=0.8, label=\"Ground Truth\" if index == 0 else None\n", " )\n", " axes[1].axvline(cp, color=\"red\", linestyle=\"--\", linewidth=2, alpha=0.8)\n", "\n", "for index, cp in enumerate(visualization_trace.signal_change_points):\n", " axes[0].axvline(cp, color=\"green\", linestyle=\"--\", linewidth=2, alpha=0.8)\n", " axes[1].axvline(\n", " cp, color=\"green\", linestyle=\"--\", linewidth=2, alpha=0.8, label=\"Detected CP\" if index == 0 else None\n", " )\n", "\n", "for period_index, (start, stop) in enumerate(manual_learning_periods):\n", " for ax in axes:\n", " ax.axvspan(\n", " start,\n", " stop,\n", " color=\"orange\",\n", " alpha=0.12,\n", " label=\"Learning Period\" if period_index == 0 and ax is axes[2] else None,\n", " )\n", "for period_index, (start, stop) in enumerate(manual_skip_periods):\n", " for ax in axes:\n", " ax.axvspan(\n", " start, stop, color=\"green\", alpha=0.10, label=\"Skip Period\" if period_index == 0 and ax is axes[2] else None\n", " )\n", "\n", "axes[0].set_title(\"Time Series with Change Points\")\n", "axes[0].set_ylabel(\"Value\")\n", "axes[1].set_title(\"Detection Function\")\n", "axes[1].set_ylabel(\"Detection Statistic\")\n", "axes[2].set_title(\"Processing Time per Step\")\n", "axes[2].set_xlabel(\"Time Index\")\n", "axes[2].set_ylabel(\"Time (seconds)\")\n", "for ax in axes:\n", " ax.grid(True, alpha=0.3)\n", " handles, labels = ax.get_legend_handles_labels()\n", " if labels:\n", " ax.legend(loc=\"upper left\", fontsize=9)\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "8a5809ec", "metadata": {}, "source": [ "### 3.4 Manual plotly overview of series and detector output\n", "\n", "The same three-panel idea is useful in plotly, especially when the reader wants to inspect individual time steps interactively. This is the plotly twin of the previous example and should be read as the same message in a different backend.\n", "\n", "The code uses `make_subplots(...)` so the layout remains parallel to the matplotlib example. That is the easiest way to keep backend comparisons visually honest.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "65c4a69a", "metadata": {}, "outputs": [], "source": [ "fig = make_subplots(\n", " rows=3,\n", " cols=1,\n", " shared_xaxes=True,\n", " vertical_spacing=0.08,\n", " subplot_titles=(\"Time Series with Change Points\", \"Detection Function\", \"Processing Time per Step\"),\n", ")\n", "time_index = np.arange(len(visualization_provider))\n", "trace_index = np.arange(len(visualization_trace.detection_function))\n", "fig.add_trace(\n", " go.Scatter(\n", " x=time_index,\n", " y=np.array(list(visualization_provider)),\n", " mode=\"lines\",\n", " name=\"Time Series\",\n", " line={\"color\": \"black\", \"width\": 1.5},\n", " opacity=0.7,\n", " ),\n", " row=1,\n", " col=1,\n", ")\n", "fig.add_trace(\n", " go.Scatter(\n", " x=trace_index,\n", " y=visualization_trace.detection_function,\n", " mode=\"lines\",\n", " name=\"Detection Function\",\n", " line={\"color\": \"blue\", \"width\": 1.0},\n", " ),\n", " row=2,\n", " col=1,\n", ")\n", "fig.add_trace(\n", " go.Scatter(\n", " x=[trace_index[0], trace_index[-1]],\n", " y=[visualization_trace.threshold, visualization_trace.threshold],\n", " mode=\"lines\",\n", " name=\"Threshold\",\n", " line={\"color\": \"red\", \"width\": 2, \"dash\": \"dash\"},\n", " ),\n", " row=2,\n", " col=1,\n", ")\n", "fig.add_trace(\n", " go.Scatter(\n", " x=trace_index,\n", " y=visualization_trace.processing_time,\n", " mode=\"lines\",\n", " name=\"Processing Time\",\n", " line={\"color\": \"purple\", \"width\": 1.0},\n", " fill=\"tozeroy\",\n", " opacity=0.7,\n", " ),\n", " row=3,\n", " col=1,\n", ")\n", "\n", "ground_truth_component_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines(list(visualization_provider.change_points))\n", " .set_style(color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Ground Truth\", legend=True)\n", ")\n", "detected_component_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines(visualization_trace.signal_change_points)\n", " .set_style(color=\"green\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Detected CP\", legend=True)\n", ")\n", "margin_component_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions([(cp - manual_margin, cp + manual_margin) for cp in visualization_provider.change_points])\n", " .set_style(fill_color=\"red\", fill_alpha=0.1, label=\"Margin Window\", legend=True)\n", ")\n", "learning_component_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions(manual_learning_periods)\n", " .set_style(fill_color=\"orange\", fill_alpha=0.12, label=\"Learning Period\", legend=True)\n", ")\n", "skip_component_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions(manual_skip_periods)\n", " .set_style(fill_color=\"green\", fill_alpha=0.10, label=\"Skip Period\", legend=True)\n", ")\n", "\n", "ground_truth_component_go.draw(fig, (1, 1), add_legend=True)\n", "ground_truth_component_go.draw(fig, (2, 1), add_legend=False)\n", "detected_component_go.draw(fig, (1, 1), add_legend=False)\n", "detected_component_go.draw(fig, (2, 1), add_legend=True)\n", "margin_component_go.draw(fig, (1, 1), add_legend=True)\n", "for target_axes in ((1, 1), (2, 1), (3, 1)):\n", " learning_component_go.draw(fig, target_axes, add_legend=target_axes == (3, 1))\n", " skip_component_go.draw(fig, target_axes, add_legend=target_axes == (3, 1))\n", "\n", "fig.update_layout(title=\"Manual Plotly Visualization of Online Detection Outputs\", height=900, showlegend=True)\n", "fig" ] }, { "cell_type": "markdown", "id": "26c9f64d", "metadata": {}, "source": [ "**Summary of Section 3:** Manual plotting makes the visualization ingredients obvious: raw signal, true boundaries, tolerance windows, detection statistics, and timing. Those ingredients are exactly what the reusable visualization classes will automate in the next sections.\n" ] }, { "cell_type": "markdown", "id": "a76fb595", "metadata": {}, "source": [ "## Section 4. Reusable Visual Components\n", "\n", "The manual plots made the necessary visual ingredients explicit, but writing those ingredients by hand every time is repetitive. The next problem is reuse: how do we express \u201cdraw these change-point lines\u201d or \u201cfill these skip regions\u201d once and then reuse that logic across figures and backends?\n", "\n", "The visualization components solve exactly that problem. They are small, backend-aware building blocks that draw one kind of annotation onto one subplot.\n" ] }, { "cell_type": "markdown", "id": "6b0eacb2", "metadata": {}, "source": [ "### 4.1 VerticalLineVisualComponent and VerticalFillComponent in matplotlib\n", "\n", "The motivation here is composability. Instead of calling `axvline(...)` and `axvspan(...)` directly every time, we package those visual ideas as reusable components with their own style and legend configuration.\n", "\n", "The matplotlib API for these components is straightforward: set the backend, configure lines or regions, optionally assign a legend label, and draw the component onto one axes object.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "d39ccd6f", "metadata": {}, "outputs": [], "source": [ "line_component = VerticalLineVisualComponent(DrawBackend.MATPLOTLIB)\n", "line_component.set_lines(visualization_provider.change_points).set_style(\n", " color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Ground Truth\", legend=True\n", ")\n", "fill_component = VerticalFillComponent(DrawBackend.MATPLOTLIB)\n", "fill_component.set_regions(\n", " [(cp - manual_margin, cp + manual_margin) for cp in visualization_provider.change_points]\n", ").set_style(fill_color=\"red\", fill_alpha=0.1, label=\"Margin Window\", legend=True)\n", "\n", "fig, ax = plt.subplots(figsize=(14, 4))\n", "ax.plot(\n", " np.arange(len(visualization_provider)),\n", " np.array(list(visualization_provider)),\n", " color=\"black\",\n", " linewidth=1.0,\n", " alpha=0.7,\n", " label=\"Time Series\",\n", ")\n", "fill_component.draw(fig, ax, add_legend=True)\n", "line_component.draw(fig, ax, add_legend=True)\n", "ax.set_title(\"Visual components on a matplotlib axes\")\n", "ax.set_xlabel(\"Time Index\")\n", "ax.set_ylabel(\"Value\")\n", "ax.grid(True, alpha=0.3)\n", "ax.legend(loc=\"upper left\")\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "7ff1888f", "metadata": {}, "source": [ "### 4.2 VerticalLineVisualComponent and VerticalFillComponent in plotly\n", "\n", "The same composability problem exists in plotly, but now the components also need to work with subplot positions rather than axes objects. This is a good example of why a backend-aware abstraction is useful: the conceptual annotation stays the same while the low-level draw mechanics change.\n", "\n", "The plotly API is parallel to the matplotlib one. The component configuration is almost identical; the main difference is that the draw target is a `(row, col)` subplot coordinate.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "c9eda690", "metadata": {}, "outputs": [], "source": [ "line_component_go = VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", "line_component_go.set_lines(visualization_provider.change_points).set_style(\n", " color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Ground Truth\", legend=True\n", ")\n", "fill_component_go = VerticalFillComponent(DrawBackend.PLOTLY)\n", "fill_component_go.set_regions(\n", " [(cp - manual_margin, cp + manual_margin) for cp in visualization_provider.change_points]\n", ").set_style(fill_color=\"red\", fill_alpha=0.1, label=\"Margin Window\", legend=True)\n", "\n", "fig = make_subplots(rows=1, cols=1, subplot_titles=(\"Visual components on a plotly subplot\",))\n", "fig.add_trace(\n", " go.Scatter(\n", " x=np.arange(len(visualization_provider)),\n", " y=np.array(list(visualization_provider)),\n", " mode=\"lines\",\n", " name=\"Time Series\",\n", " line={\"color\": \"black\", \"width\": 1.5},\n", " opacity=0.7,\n", " ),\n", " row=1,\n", " col=1,\n", ")\n", "fill_component_go.draw(fig, (1, 1), add_legend=True)\n", "line_component_go.draw(fig, (1, 1), add_legend=True)\n", "fig.update_layout(height=350)\n", "fig" ] }, { "cell_type": "markdown", "id": "learning-skip-components-md", "metadata": {}, "source": [ "### 4.3 Learning and skip periods as reusable regions\n", "\n", "Learning periods and skip periods are interval annotations rather than point annotations. They use the same `VerticalFillComponent` abstraction as margin windows, but they communicate detector runtime state rather than ground truth.\n", "\n", "This example draws them on the detection-function panel, where their interpretation is most direct: learning periods explain why early statistics should not be treated like ordinary detections, and skip periods explain why the detector suppresses immediate repeated alarms after a reset.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "learning-skip-components-code", "metadata": {}, "outputs": [], "source": [ "learning_component = VerticalFillComponent(DrawBackend.MATPLOTLIB)\n", "learning_component.set_regions(manual_learning_periods).set_style(\n", " fill_color=\"orange\", fill_alpha=0.12, label=\"Learning Period\", legend=True\n", ")\n", "skip_component = VerticalFillComponent(DrawBackend.MATPLOTLIB)\n", "skip_component.set_regions(manual_skip_periods).set_style(\n", " fill_color=\"green\", fill_alpha=0.10, label=\"Skip Period\", legend=True\n", ")\n", "detected_component = VerticalLineVisualComponent(DrawBackend.MATPLOTLIB)\n", "detected_component.set_lines(visualization_trace.signal_change_points).set_style(\n", " color=\"green\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Detected CP\", legend=True\n", ")\n", "\n", "fig, ax = plt.subplots(figsize=(14, 4))\n", "ax.plot(visualization_trace.detection_function, color=\"blue\", linewidth=1.0, label=\"Detection Function\")\n", "ax.axhline(visualization_trace.threshold, color=\"red\", linestyle=\"--\", linewidth=2, label=\"Threshold\")\n", "learning_component.draw(fig, ax, add_legend=True)\n", "skip_component.draw(fig, ax, add_legend=True)\n", "detected_component.draw(fig, ax, add_legend=True)\n", "ax.set_title(\"Learning and skip periods as reusable fill components\")\n", "ax.set_xlabel(\"Time Index\")\n", "ax.set_ylabel(\"Statistic\")\n", "ax.grid(True, alpha=0.3)\n", "ax.legend(loc=\"upper left\")\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "e0768b68", "metadata": {}, "source": [ "**Summary of Section 4:** Visual components are the reusable annotation layer of the plotting system. They let you express repeated visual ideas once, including point markers for change points and interval fills for margins, learning periods, and skip periods.\n" ] }, { "cell_type": "markdown", "id": "07dff027", "metadata": {}, "source": [ "## Section 5. Direct Visualizers\n", "\n", "Manual plotting and low-level components are useful, but the next problem is higher-level reuse. We do not just want reusable lines and fills; we want reusable \u201cdraw the timeseries\u201d and \u201cdraw the trace\u201d logic as well.\n", "\n", "The direct visualizer classes solve that problem. They are still explicit enough to configure carefully, but high-level enough that the figure-building code becomes much shorter than the manual equivalent.\n" ] }, { "cell_type": "markdown", "id": "6b552d7b", "metadata": {}, "source": [ "### 5.1 UnivariateTimeseriesVisualizer and OnlineTraceVisualizer in matplotlib\n", "\n", "The first direct-visualizer problem is producing the same three-panel figure we drew manually, but now through dedicated plotting classes. The motivation is not shorter code for its own sake; it is consistency across repeated uses.\n", "\n", "The API works in two stages. First, configure the visualizers with the provider or trace and any style options. Second, draw them into a figure whose axes mapping names match the required subplot names.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "0f49ad47", "metadata": {}, "outputs": [], "source": [ "ground_truth_component = (\n", " VerticalLineVisualComponent(DrawBackend.MATPLOTLIB)\n", " .set_lines(visualization_provider.change_points)\n", " .set_style(color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Ground Truth\", legend=True)\n", ")\n", "detected_component = (\n", " VerticalLineVisualComponent(DrawBackend.MATPLOTLIB)\n", " .set_lines(visualization_trace.signal_change_points)\n", " .set_style(color=\"green\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Detected CP\", legend=True)\n", ")\n", "timeseries_region_components = [\n", " VerticalFillComponent(DrawBackend.MATPLOTLIB)\n", " .set_regions([(cp - manual_margin, cp + manual_margin) for cp in visualization_provider.change_points])\n", " .set_style(fill_color=\"red\", fill_alpha=0.1, label=\"Margin Window\", legend=True),\n", "]\n", "detection_region_components = [\n", " VerticalFillComponent(DrawBackend.MATPLOTLIB)\n", " .set_regions(manual_learning_periods)\n", " .set_style(fill_color=\"orange\", fill_alpha=0.12, label=\"Learning Period\", legend=True),\n", " VerticalFillComponent(DrawBackend.MATPLOTLIB)\n", " .set_regions(manual_skip_periods)\n", " .set_style(fill_color=\"green\", fill_alpha=0.10, label=\"Skip Period\", legend=True),\n", "]\n", "\n", "backend = DrawBackend.MATPLOTLIB\n", "timeseries_visualizer = UnivariateTimeseriesVisualizer(backend=backend)\n", "timeseries_visualizer.set_data_provider(visualization_provider)\n", "(\n", " timeseries_visualizer.set_plot_opts(\n", " title=\"Time Series with Change Points\", xlabel=\"Time Index\", ylabel=\"Value\", grid=True\n", " ).set_draw_opts(color=\"black\", linewidth=1.5, alpha=0.7, label=\"Time Series\")\n", ")\n", "\n", "trace_visualizer = OnlineTraceVisualizer(\n", " backend=backend,\n", " state_visualizer=DummyStateVisualizer[ShewhartControlChartState](backend=backend),\n", ")\n", "trace_visualizer.set_trace(visualization_trace)\n", "(\n", " trace_visualizer.set_detection_func_plot_opts(\n", " title=\"Detection Function\", xlabel=\"Time Index\", ylabel=\"Detection Statistic\", grid=True\n", " )\n", " .set_detection_func_draw_opts(color=\"blue\", linewidth=1, label=\"Detection Function\")\n", " .set_threshold_draw_opts(color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Threshold\")\n", " .set_processing_time_plot_opts(\n", " title=\"Processing Time per Step\", xlabel=\"Time Index\", ylabel=\"Time (seconds)\", grid=True\n", " )\n", " .set_processing_time_draw_opts(color=\"purple\", linewidth=1, fill_alpha=0.3, label=\"Processing Time\")\n", ")\n", "\n", "fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)\n", "fig.suptitle(\"Direct visualizers (matplotlib)\", fontsize=16, fontweight=\"bold\")\n", "ax_mapping = {\"timeseries\": axes[0], \"detection_function\": axes[1], \"processing_time\": axes[2]}\n", "fig = timeseries_visualizer.draw(figure=fig, axes=ax_mapping)\n", "fig = trace_visualizer.draw(figure=fig, axes=ax_mapping)\n", "ground_truth_component.draw(fig, axes[0], add_legend=True)\n", "ground_truth_component.draw(fig, axes[1], add_legend=False)\n", "detected_component.draw(fig, axes[0], add_legend=False)\n", "detected_component.draw(fig, axes[1], add_legend=True)\n", "for component in timeseries_region_components:\n", " component.draw(fig, axes[0], add_legend=True)\n", "for component in detection_region_components:\n", " for index, ax in enumerate(axes):\n", " component.draw(fig, ax, add_legend=index == 2)\n", "for ax in axes:\n", " handles, labels = ax.get_legend_handles_labels()\n", " if labels:\n", " ax.legend(loc=\"upper left\", fontsize=9)\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "3d215e1e", "metadata": {}, "source": [ "### 5.2 UnivariateTimeseriesVisualizer and OnlineTraceVisualizer in plotly\n", "\n", "The same direct-visualizer problem should look as parallel as possible in plotly. That is why this subsection repeats the same conceptual figure and almost the same configuration choices, but swaps the backend and subplot mapping.\n", "\n", "Reading the two code cells side by side is useful because it shows what really changes with the backend and what stays conceptually identical across both rendering systems.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "d25ee05a", "metadata": {}, "outputs": [], "source": [ "ground_truth_component_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines(visualization_provider.change_points)\n", " .set_style(color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Ground Truth\", legend=True)\n", ")\n", "detected_component_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines(visualization_trace.signal_change_points)\n", " .set_style(color=\"green\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Detected CP\", legend=True)\n", ")\n", "timeseries_region_components_go = [\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions([(cp - manual_margin, cp + manual_margin) for cp in visualization_provider.change_points])\n", " .set_style(fill_color=\"red\", fill_alpha=0.1, label=\"Margin Window\", legend=True),\n", "]\n", "detection_region_components_go = [\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions(manual_learning_periods)\n", " .set_style(fill_color=\"orange\", fill_alpha=0.12, label=\"Learning Period\", legend=True),\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions(manual_skip_periods)\n", " .set_style(fill_color=\"green\", fill_alpha=0.10, label=\"Skip Period\", legend=True),\n", "]\n", "\n", "backend = DrawBackend.PLOTLY\n", "timeseries_visualizer_go = UnivariateTimeseriesVisualizer(backend=backend)\n", "timeseries_visualizer_go.set_data_provider(visualization_provider)\n", "(\n", " timeseries_visualizer_go.set_plot_opts(\n", " title=\"Time Series with Change Points\", xlabel=\"Time Index\", ylabel=\"Value\", grid=True\n", " ).set_draw_opts(color=\"black\", linewidth=1.5, alpha=0.7, label=\"Time Series\")\n", ")\n", "\n", "trace_visualizer_go = OnlineTraceVisualizer(\n", " backend=backend,\n", " state_visualizer=DummyStateVisualizer[ShewhartControlChartState](backend=backend),\n", ")\n", "trace_visualizer_go.set_trace(visualization_trace)\n", "(\n", " trace_visualizer_go.set_detection_func_plot_opts(\n", " title=\"Detection Function\", xlabel=\"Time Index\", ylabel=\"Detection Statistic\", grid=True\n", " )\n", " .set_detection_func_draw_opts(color=\"blue\", linewidth=1, label=\"Detection Function\")\n", " .set_threshold_draw_opts(color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Threshold\")\n", " .set_processing_time_plot_opts(\n", " title=\"Processing Time per Step\", xlabel=\"Time Index\", ylabel=\"Time (seconds)\", grid=True\n", " )\n", " .set_processing_time_draw_opts(color=\"purple\", linewidth=1, fill_alpha=0.3, label=\"Processing Time\")\n", ")\n", "\n", "fig = make_subplots(\n", " rows=3,\n", " cols=1,\n", " shared_xaxes=True,\n", " vertical_spacing=0.1,\n", " subplot_titles=(\"Time Series with Change Points\", \"Detection Function\", \"Processing Time per Step\"),\n", ")\n", "ax_mapping = {\"timeseries\": (1, 1), \"detection_function\": (2, 1), \"processing_time\": (3, 1)}\n", "fig = timeseries_visualizer_go.draw(figure=fig, axes=ax_mapping)\n", "fig = trace_visualizer_go.draw(figure=fig, axes=ax_mapping)\n", "ground_truth_component_go.draw(fig, (1, 1), add_legend=True)\n", "ground_truth_component_go.draw(fig, (2, 1), add_legend=False)\n", "detected_component_go.draw(fig, (1, 1), add_legend=False)\n", "detected_component_go.draw(fig, (2, 1), add_legend=True)\n", "for component in timeseries_region_components_go:\n", " component.draw(fig, (1, 1), add_legend=True)\n", "for component in detection_region_components_go:\n", " for target_axes in ((1, 1), (2, 1), (3, 1)):\n", " component.draw(fig, target_axes, add_legend=target_axes == (3, 1))\n", "fig.update_layout(title=\"Direct visualizers (plotly)\", height=900, showlegend=True)\n", "fig" ] }, { "cell_type": "markdown", "id": "ca17010c", "metadata": {}, "source": [ "### 5.3 Custom Shewhart state visualizer in matplotlib\n", "\n", "The next visualization problem is algorithm state. A detection statistic alone does not explain how the Shewhart algorithm\u2019s internal estimates evolved. The old tutorial had a custom state-visualizer example, and that idea is still valuable.\n", "\n", "`ShewhartStateVisualizer` solves this by rendering running means, control bands, and window means from the stored `ShewhartControlChartState` snapshots. In matplotlib, that means we need one extra subplot mapping entry dedicated to the state panel.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "987ebff1", "metadata": {}, "outputs": [], "source": [ "state_visualizer = ShewhartStateVisualizer(DrawBackend.MATPLOTLIB)\n", "state_visualizer.set_plot_opts(title=\"Shewhart State Evolution\", xlabel=\"Time Index\", ylabel=\"Value\", grid=True)\n", "state_visualizer.set_band_opts(band_size=3.0)\n", "\n", "trace_visualizer_state = OnlineTraceVisualizer(\n", " backend=DrawBackend.MATPLOTLIB,\n", " state_visualizer=state_visualizer,\n", ")\n", "trace_visualizer_state.set_trace(visualization_trace)\n", "\n", "fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True)\n", "ax_mapping = {\n", " \"timeseries\": axes[0],\n", " \"detection_function\": axes[1],\n", " \"processing_time\": axes[2],\n", " \"shewhart_state\": axes[3],\n", "}\n", "fig = timeseries_visualizer.draw(figure=fig, axes=ax_mapping)\n", "fig = trace_visualizer_state.draw(figure=fig, axes=ax_mapping)\n", "ground_truth_component.draw(fig, axes[0], add_legend=True)\n", "ground_truth_component.draw(fig, axes[1], add_legend=False)\n", "for index, ax in enumerate((axes[0], axes[1], axes[3])):\n", " detected_component.draw(fig, ax, add_legend=index == 1)\n", "for component in timeseries_region_components:\n", " component.draw(fig, axes[0], add_legend=True)\n", "for component in detection_region_components:\n", " for index, ax in enumerate(axes):\n", " component.draw(fig, ax, add_legend=index == 2)\n", "for ax in axes:\n", " handles, labels = ax.get_legend_handles_labels()\n", " if labels:\n", " ax.legend(loc=\"upper left\", fontsize=9)\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "0778552a", "metadata": {}, "source": [ "### 5.4 Custom Shewhart state visualizer in plotly\n", "\n", "The same state-evolution problem is worth solving in plotly because interactive hovering can make state trajectories easier to inspect at specific steps. The conceptual content of the plot is unchanged; only the rendering backend differs.\n", "\n", "As before, the important API detail is the subplot mapping. The state visualizer declares its own axis name, so the figure layout has to provide a subplot position for that axis explicitly.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "6c468330", "metadata": {}, "outputs": [], "source": [ "state_visualizer_go = ShewhartStateVisualizer(DrawBackend.PLOTLY)\n", "state_visualizer_go.set_plot_opts(title=\"Shewhart State Evolution\", xlabel=\"Time Index\", ylabel=\"Value\", grid=True)\n", "state_visualizer_go.set_band_opts(band_size=3.0)\n", "\n", "trace_visualizer_state_go = OnlineTraceVisualizer(\n", " backend=DrawBackend.PLOTLY,\n", " state_visualizer=state_visualizer_go,\n", ")\n", "trace_visualizer_state_go.set_trace(visualization_trace)\n", "\n", "fig = make_subplots(\n", " rows=4,\n", " cols=1,\n", " shared_xaxes=True,\n", " vertical_spacing=0.08,\n", " subplot_titles=(\n", " \"Time Series with Change Points\",\n", " \"Detection Function\",\n", " \"Processing Time per Step\",\n", " \"Shewhart State Evolution\",\n", " ),\n", ")\n", "ax_mapping = {\n", " \"timeseries\": (1, 1),\n", " \"detection_function\": (2, 1),\n", " \"processing_time\": (3, 1),\n", " \"shewhart_state\": (4, 1),\n", "}\n", "fig = timeseries_visualizer_go.draw(figure=fig, axes=ax_mapping)\n", "fig = trace_visualizer_state_go.draw(figure=fig, axes=ax_mapping)\n", "ground_truth_component_go.draw(fig, (1, 1), add_legend=True)\n", "ground_truth_component_go.draw(fig, (2, 1), add_legend=False)\n", "for target_axes in ((1, 1), (2, 1), (4, 1)):\n", " detected_component_go.draw(fig, target_axes, add_legend=target_axes == (2, 1))\n", "for component in timeseries_region_components_go:\n", " component.draw(fig, (1, 1), add_legend=True)\n", "for component in detection_region_components_go:\n", " for target_axes in ((1, 1), (2, 1), (3, 1), (4, 1)):\n", " component.draw(fig, target_axes, add_legend=target_axes == (3, 1))\n", "fig.update_layout(title=\"Custom Shewhart state visualizer (plotly)\", height=1200, showlegend=True)\n", "fig" ] }, { "cell_type": "markdown", "id": "0bef823e", "metadata": {}, "source": [ "**Summary of Section 5:** Direct visualizers raise the abstraction level from \u201cdraw one annotation\u201d to \u201cdraw one semantic panel\u201d. They are detailed enough to customize carefully, but high-level enough to keep repeated plotting code consistent across runs and across backends.\n" ] }, { "cell_type": "markdown", "id": "c1e79883-9fa2-481b-a5f9-ce6256131623", "metadata": {}, "source": [ "## Section 6. Multivariate Time-Series Visualizers\n", "\n", "The univariate examples show the basic visualization architecture, but many practical detection problems involve several signals evolving together. The multivariate API now has two intentionally different designs.\n", "\n", "`PlainMultivariateTimeseriesVisualizer` is dimension-oriented: one subplot per dimension, simple provider assumptions, and an `axes=[...]` configuration style. `RichMultivariateTimeseriesVisualizer` is plot-oriented: it is pandas-first, uses explicit logical plot names and series names, supports optional custom time columns, and can place selected series on a twin y-axis.\n" ] }, { "cell_type": "markdown", "id": "4e90115b-73ec-424e-99f7-a12f1327bad2", "metadata": {}, "source": [ "### 6.1 Preparing multivariate providers\n", "\n", "The first multivariate problem is getting one common source that can drive both visualizer styles. We build one synthetic three-feature series, then derive both a plain labeled provider and a pandas-backed labeled provider from the same generated data.\n", "\n", "To demonstrate `set_time_column(...)`, we also create a pandas-backed variant with an artificial elapsed-time column. That lets us compare positional x-values with an explicit custom time axis without changing the underlying signals.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "3e7815b9-d52c-4c9c-82d8-978cc8ffc2d7", "metadata": {}, "outputs": [], "source": [ "multivariate_visualization_scenario = ScenarioSpec(\n", " name=\"multivariate_visualization_demo\",\n", " segments=(\n", " SegmentSpec(plan_name=\"baseline\", length=70),\n", " SegmentSpec(plan_name=\"shifted\", length=60),\n", " SegmentSpec(plan_name=\"recovery\", length=50),\n", " ),\n", " plans=frozendict(\n", " baseline=SegmentPlan(\n", " distribution=IndependentColumnsSpec(\n", " columns=frozendict(\n", " sensor_a=NormalSpec(mean=0.0, std=0.6),\n", " sensor_b=NormalSpec(mean=2.0, std=0.4),\n", " load=NormalSpec(mean=20.0, std=1.2),\n", " )\n", " ),\n", " state=StateDescriptor(type=\"baseline\"),\n", " name=\"baseline\",\n", " ),\n", " shifted=SegmentPlan(\n", " distribution=IndependentColumnsSpec(\n", " columns=frozendict(\n", " sensor_a=NormalSpec(mean=2.8, std=0.7),\n", " sensor_b=NormalSpec(mean=0.8, std=0.5),\n", " load=NormalSpec(mean=34.0, std=1.6),\n", " )\n", " ),\n", " state=StateDescriptor(type=\"shifted\"),\n", " name=\"shifted\",\n", " ),\n", " recovery=SegmentPlan(\n", " distribution=IndependentColumnsSpec(\n", " columns=frozendict(\n", " sensor_a=NormalSpec(mean=1.1, std=0.5),\n", " sensor_b=NormalSpec(mean=1.7, std=0.4),\n", " load=NormalSpec(mean=24.0, std=1.1),\n", " )\n", " ),\n", " state=StateDescriptor(type=\"recovery\"),\n", " name=\"recovery\",\n", " ),\n", " ),\n", ")\n", "multivariate_visualization_series = GenericSeriesGenerator(seed=23).generate_from_scenario(\n", " multivariate_visualization_scenario,\n", " name=\"multivariate_visualization_series\",\n", ")\n", "multivariate_plain_provider = build_plain_multivariate_labeled_data(\n", " multivariate_visualization_series,\n", " name=\"multivariate_plain_provider\",\n", ")\n", "multivariate_pandas_provider = build_pandas_labeled_data(\n", " multivariate_visualization_series,\n", " name=\"multivariate_pandas_provider\",\n", ")\n", "multivariate_pandas_provider_with_time = multivariate_pandas_provider.create_feature_column(\n", " name=\"elapsed_seconds\",\n", " mapping=lambda row: row.name * 0.5,\n", ")\n", "multivariate_trace_provider = build_plain_univariate_labeled_data(\n", " multivariate_visualization_series,\n", " feature_name=\"sensor_a\",\n", " name=\"multivariate_trace_provider\",\n", ")\n", "multivariate_detector = OnlineResetDetector(\n", " ShewhartControlChart(learning_period_size=30, window_size=10),\n", " threshold=2.0,\n", " skip_period=8,\n", " collect_states=True,\n", ")\n", "multivariate_visualization_trace = multivariate_detector.detect(multivariate_trace_provider)\n", "\n", "print(\"Plain provider dimensionality:\", multivariate_plain_provider.raw_data.shape[1])\n", "print(\"Pandas feature columns:\", list(multivariate_pandas_provider.feature_columns))\n", "print(\n", " \"Pandas feature columns with time:\",\n", " list(multivariate_pandas_provider_with_time.feature_columns),\n", ")\n", "print(\"Multivariate trace change points:\", multivariate_visualization_trace.signal_change_points)" ] }, { "cell_type": "markdown", "id": "f6aa6fb1-48df-41c6-9db7-38975fa41051", "metadata": {}, "source": [ "### 6.2 PlainMultivariateTimeseriesVisualizer in matplotlib\n", "\n", "The plain multivariate visualizer preserves the simple mental model: one subplot per dimension. This makes it a good fit for quick inspection of multivariate arrays when each feature deserves its own panel.\n", "\n", "This matplotlib example uses integer axis selectors and keeps the styling close to the univariate examples, but now repeated once per dimension.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "f69991a3-d0aa-4686-bb7a-ffdb71cd0b70", "metadata": {}, "outputs": [], "source": [ "plain_multivariate_visualizer = PlainMultivariateTimeseriesVisualizer(\n", " DrawBackend.MATPLOTLIB,\n", " dimensionality=3,\n", ")\n", "plain_multivariate_visualizer.set_data_provider(multivariate_plain_provider)\n", "plain_multivariate_visualizer.set_plot_opts(axes=[0, 1, 2], xlabel=\"Sample Index\", grid=True)\n", "plain_multivariate_visualizer.set_plot_opts(axes=[0], title=\"Sensor A\", ylabel=\"Sensor A\")\n", "plain_multivariate_visualizer.set_plot_opts(axes=[1], title=\"Sensor B\", ylabel=\"Sensor B\")\n", "plain_multivariate_visualizer.set_plot_opts(axes=[2], title=\"Load\", ylabel=\"Load\")\n", "plain_multivariate_visualizer.set_draw_opts(axes=[0], color=\"tab:blue\", label=\"sensor_a\")\n", "plain_multivariate_visualizer.set_draw_opts(axes=[1], color=\"tab:orange\", label=\"sensor_b\")\n", "plain_multivariate_visualizer.set_draw_opts(axes=[2], color=\"tab:green\", label=\"load\")\n", "\n", "fig, axes = plt.subplots(3, 1, figsize=(14, 9), sharex=True)\n", "fig.suptitle(\"PlainMultivariateTimeseriesVisualizer (matplotlib)\", fontsize=16, fontweight=\"bold\")\n", "plain_multivariate_visualizer.draw(\n", " figure=fig,\n", " axes={\n", " \"timeseries_0\": axes[0],\n", " \"timeseries_1\": axes[1],\n", " \"timeseries_2\": axes[2],\n", " },\n", ")\n", "for ax in axes:\n", " ax.legend(loc=\"upper left\")\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "c8395fdf-4948-4fe6-b472-089450fbbd55", "metadata": {}, "source": [ "### 6.3 PlainMultivariateTimeseriesVisualizer in plotly\n", "\n", "The same plain design works in plotly as well. Here the useful extra demonstration is named axis selection: because the pandas-backed provider exposes feature columns, the plain visualizer can resolve subplot styling by column name instead of only by integer index.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "c18a8ee4-4a46-4bd5-97e2-38859a46046b", "metadata": {}, "outputs": [], "source": [ "plain_multivariate_visualizer_go = PlainMultivariateTimeseriesVisualizer(\n", " DrawBackend.PLOTLY,\n", " dimensionality=3,\n", ")\n", "plain_multivariate_visualizer_go.set_data_provider(multivariate_pandas_provider)\n", "plain_multivariate_visualizer_go.set_plot_opts(axes=[\"sensor_a\", \"sensor_b\", \"load\"], xlabel=\"Sample Index\", grid=True)\n", "plain_multivariate_visualizer_go.set_plot_opts(axes=[\"sensor_a\"], title=\"Sensor A\", ylabel=\"Sensor A\")\n", "plain_multivariate_visualizer_go.set_plot_opts(axes=[\"sensor_b\"], title=\"Sensor B\", ylabel=\"Sensor B\")\n", "plain_multivariate_visualizer_go.set_plot_opts(axes=[\"load\"], title=\"Load\", ylabel=\"Load\")\n", "plain_multivariate_visualizer_go.set_draw_opts(axes=[\"sensor_a\"], color=\"tab:blue\", label=\"sensor_a\")\n", "plain_multivariate_visualizer_go.set_draw_opts(axes=[\"sensor_b\"], color=\"tab:orange\", label=\"sensor_b\")\n", "plain_multivariate_visualizer_go.set_draw_opts(axes=[\"load\"], color=\"tab:green\", label=\"load\")\n", "\n", "fig = make_subplots(\n", " rows=3,\n", " cols=1,\n", " shared_xaxes=True,\n", " vertical_spacing=0.08,\n", " subplot_titles=(\"Sensor A\", \"Sensor B\", \"Load\"),\n", ")\n", "plain_multivariate_visualizer_go.draw(\n", " figure=fig,\n", " axes={\n", " \"timeseries_0\": (1, 1),\n", " \"timeseries_1\": (2, 1),\n", " \"timeseries_2\": (3, 1),\n", " },\n", ")\n", "fig.update_layout(title=\"PlainMultivariateTimeseriesVisualizer (plotly)\", height=900, showlegend=True)\n", "fig" ] }, { "cell_type": "markdown", "id": "d84d94f1-4fca-4ed6-beca-88b76391d86a", "metadata": {}, "source": [ "### 6.4 RichMultivariateTimeseriesVisualizer in matplotlib without a custom time column\n", "\n", "The rich multivariate visualizer changes the organizing idea. Instead of treating dimensions as the primary concept, it lets us define logical plots and then bind named series into them.\n", "\n", "This first rich example keeps the x-axis positional. That means the visualizer uses row index by default, which is often enough when the data is sampled uniformly and an explicit timestamp column is unnecessary.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "7a5ea45b-cbd3-4d75-8043-911d708be066", "metadata": {}, "outputs": [], "source": [ "rich_multivariate_visualizer = RichMultivariateTimeseriesVisualizer(DrawBackend.MATPLOTLIB)\n", "rich_multivariate_visualizer.set_data_provider(multivariate_pandas_provider)\n", "rich_multivariate_visualizer.define_plot(\n", " \"signals\",\n", " title=\"Signals on a shared logical plot\",\n", " xlabel=\"Sample Index\",\n", " ylabel=\"Signal Value\",\n", " grid=True,\n", ")\n", "rich_multivariate_visualizer.define_plot(\n", " \"load\",\n", " title=\"Load on a separate logical plot\",\n", " xlabel=\"Sample Index\",\n", " ylabel=\"Load\",\n", " grid=True,\n", ")\n", "rich_multivariate_visualizer.add_series(\"signals\", \"sensor_a\", column=\"sensor_a\", color=\"tab:blue\")\n", "rich_multivariate_visualizer.add_series(\"signals\", \"sensor_b\", column=\"sensor_b\", color=\"tab:orange\")\n", "rich_multivariate_visualizer.add_series(\"load\", \"load\", column=\"load\", color=\"tab:green\")\n", "\n", "fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)\n", "fig.suptitle(\"RichMultivariateTimeseriesVisualizer (matplotlib, positional x)\", fontsize=16, fontweight=\"bold\")\n", "rich_multivariate_visualizer.draw(\n", " figure=fig,\n", " axes={\n", " \"signals\": axes[0],\n", " \"load\": axes[1],\n", " },\n", ")\n", "for ax in axes:\n", " handles, labels = ax.get_legend_handles_labels()\n", " if labels:\n", " ax.legend(loc=\"upper left\")\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "a1857faa-f112-4c56-9336-ff89962c53e1", "metadata": {}, "source": [ "### 6.5 RichMultivariateTimeseriesVisualizer in plotly without a custom time column\n", "\n", "The same logical-plot API works in plotly. This version keeps the same positional x-axis assumption, so the backend difference is purely about rendering and interactivity rather than data binding.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "f536b54c-d86a-4610-b2ee-44ba06a6551b", "metadata": {}, "outputs": [], "source": [ "rich_multivariate_visualizer_go = RichMultivariateTimeseriesVisualizer(DrawBackend.PLOTLY)\n", "rich_multivariate_visualizer_go.set_data_provider(multivariate_pandas_provider)\n", "rich_multivariate_visualizer_go.define_plot(\n", " \"signals\",\n", " title=\"Signals on a shared logical plot\",\n", " xlabel=\"Sample Index\",\n", " ylabel=\"Signal Value\",\n", " grid=True,\n", ")\n", "rich_multivariate_visualizer_go.define_plot(\n", " \"load\",\n", " title=\"Load on a separate logical plot\",\n", " xlabel=\"Sample Index\",\n", " ylabel=\"Load\",\n", " grid=True,\n", ")\n", "rich_multivariate_visualizer_go.add_series(\"signals\", \"sensor_a\", column=\"sensor_a\", color=\"tab:blue\")\n", "rich_multivariate_visualizer_go.add_series(\"signals\", \"sensor_b\", column=\"sensor_b\", color=\"tab:orange\")\n", "rich_multivariate_visualizer_go.add_series(\"load\", \"load\", column=\"load\", color=\"tab:green\")\n", "\n", "fig = make_subplots(\n", " rows=2,\n", " cols=1,\n", " shared_xaxes=True,\n", " vertical_spacing=0.08,\n", " subplot_titles=(\"Signals on a shared logical plot\", \"Load on a separate logical plot\"),\n", ")\n", "rich_multivariate_visualizer_go.draw(\n", " figure=fig,\n", " axes={\n", " \"signals\": (1, 1),\n", " \"load\": (2, 1),\n", " },\n", ")\n", "fig.update_layout(title=\"RichMultivariateTimeseriesVisualizer (plotly, positional x)\", height=800, showlegend=True)\n", "fig" ] }, { "cell_type": "markdown", "id": "480822d8", "metadata": {}, "source": [ "### 6.6 RichMultivariateTimeseriesVisualizer in matplotlib with a custom time column and twin axis\n", "\n", "A richer use case is when the x-axis should come from an explicit provider column rather than from positional index. Here we bind `elapsed_seconds` as the time axis and place `load` on a twin y-axis so that the sensor traces and the load trace can share one logical plot without forcing them onto the same numeric scale." ] }, { "cell_type": "code", "execution_count": null, "id": "28b37c46-e074-4b51-9f9a-62b1244d5692", "metadata": {}, "outputs": [], "source": [ "rich_multivariate_visualizer_time = RichMultivariateTimeseriesVisualizer(DrawBackend.MATPLOTLIB)\n", "rich_multivariate_visualizer_time.set_data_provider(multivariate_pandas_provider_with_time)\n", "rich_multivariate_visualizer_time.set_time_column(\"elapsed_seconds\")\n", "rich_multivariate_visualizer_time.define_plot(\n", " \"operations\",\n", " title=\"Signals and load with a custom time axis\",\n", " xlabel=\"Elapsed Seconds\",\n", " ylabel=\"Sensor Value\",\n", " ylabel_twin=\"Load\",\n", " grid=True,\n", ")\n", "rich_multivariate_visualizer_time.add_series(\"operations\", \"sensor_a\", column=\"sensor_a\", color=\"tab:blue\")\n", "rich_multivariate_visualizer_time.add_series(\"operations\", \"sensor_b\", column=\"sensor_b\", color=\"tab:orange\")\n", "rich_multivariate_visualizer_time.add_series(\n", " \"operations\",\n", " \"load\",\n", " column=\"load\",\n", " twin=True,\n", " color=\"tab:green\",\n", " linestyle=\"dash\",\n", ")\n", "\n", "fig, ax = plt.subplots(figsize=(14, 5))\n", "fig.suptitle(\"RichMultivariateTimeseriesVisualizer (matplotlib, custom time + twin axis)\", fontsize=16)\n", "rich_multivariate_visualizer_time.draw(\n", " figure=fig,\n", " axes={\"operations\": ax},\n", ")\n", "handles, labels = ax.get_legend_handles_labels()\n", "if labels:\n", " ax.legend(loc=\"upper left\")\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "aa1c2784-1482-432a-812a-588cc1fe0630", "metadata": {}, "source": [ "### 6.7 RichMultivariateTimeseriesVisualizer in plotly with a custom time column and twin axis\n", "\n", "The plotly version uses the same rich API but draws into a subplot configured with `secondary_y=True`. This keeps the public visualizer API backend-agnostic while still making the secondary-axis behavior explicit in the figure setup.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "9395ce56-8e49-4123-9b6f-75b64382f0a8", "metadata": {}, "outputs": [], "source": [ "rich_multivariate_visualizer_time_go = RichMultivariateTimeseriesVisualizer(DrawBackend.PLOTLY)\n", "rich_multivariate_visualizer_time_go.set_data_provider(multivariate_pandas_provider_with_time)\n", "rich_multivariate_visualizer_time_go.set_time_column(\"elapsed_seconds\")\n", "rich_multivariate_visualizer_time_go.define_plot(\n", " \"operations\",\n", " title=\"Signals and load with a custom time axis\",\n", " xlabel=\"Elapsed Seconds\",\n", " ylabel=\"Sensor Value\",\n", " ylabel_twin=\"Load\",\n", " grid=True,\n", ")\n", "rich_multivariate_visualizer_time_go.add_series(\"operations\", \"sensor_a\", column=\"sensor_a\", color=\"tab:blue\")\n", "rich_multivariate_visualizer_time_go.add_series(\"operations\", \"sensor_b\", column=\"sensor_b\", color=\"tab:orange\")\n", "rich_multivariate_visualizer_time_go.add_series(\n", " \"operations\",\n", " \"load\",\n", " column=\"load\",\n", " twin=True,\n", " color=\"tab:green\",\n", " linestyle=\"dash\",\n", ")\n", "\n", "fig = make_subplots(\n", " rows=1,\n", " cols=1,\n", " specs=[[{\"secondary_y\": True}]],\n", " subplot_titles=(\"Signals and load with a custom time axis\",),\n", ")\n", "rich_multivariate_visualizer_time_go.draw(\n", " figure=fig,\n", " axes={\"operations\": (1, 1)},\n", ")\n", "fig.update_layout(\n", " title=\"RichMultivariateTimeseriesVisualizer (plotly, custom time + twin axis)\", height=450, showlegend=True\n", ")\n", "fig" ] }, { "cell_type": "markdown", "id": "53c05146-fcd9-4e85-9c01-a5e048a93b5f", "metadata": {}, "source": [ "**Summary of Section 6:** The multivariate visualization API now has two clear layers. `PlainMultivariateTimeseriesVisualizer` is best for simple one-dimension-per-panel inspection, while `RichMultivariateTimeseriesVisualizer` is designed for pandas-backed grouped plots, optional custom time columns, and twin-axis layouts.\n" ] }, { "cell_type": "markdown", "id": "e8c22de4", "metadata": {}, "source": [ "## Section 7. OnlineCpdPlotter\n", "\n", "The next problem is figure coordination. Even direct visualizers still leave us responsible for wiring together the time-series panel, trace panels, and shared annotation components. `OnlineCpdPlotter` solves that coordination problem.\n", "\n", "This is the highest-level plotting abstraction for online detection results in the current API. It owns default visualizers, default components, and a coordinated draw order, while still allowing explicit overrides when needed." ] }, { "cell_type": "markdown", "id": "bd9b76ca", "metadata": {}, "source": [ "### 7.1 OnlineCpdPlotter in matplotlib\n", "\n", "The plotter\u2019s main motivation is convenience without losing structure. Instead of manually drawing the timeseries visualizer, the trace visualizer, and several components in the right order, we let one coordinator do it.\n", "\n", "The matplotlib API is straightforward: construct the plotter with a backend, provider, and trace; set ground-truth information; provide an axes mapping; then call `draw(...)`.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "2d57e8ba", "metadata": {}, "outputs": [], "source": [ "plotter = OnlineCpdPlotter(\n", " backend=DrawBackend.MATPLOTLIB,\n", " data_provider=visualization_provider,\n", " detection_trace=visualization_trace,\n", ")\n", "plotter.set_ground_truth(list(visualization_provider.change_points), margin=manual_margin)\n", "plotter.set_legend_axis(\"detection_function\")\n", "fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)\n", "fig.suptitle(\"OnlineCpdPlotter (matplotlib)\", fontsize=16)\n", "fig = plotter.draw(\n", " figure=fig,\n", " axes={\"timeseries\": axes[0], \"detection_function\": axes[1], \"processing_time\": axes[2]},\n", ")\n", "for ax in axes:\n", " handles, labels = ax.get_legend_handles_labels()\n", " if labels:\n", " ax.legend(loc=\"upper left\", fontsize=9)\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "081be2ef", "metadata": {}, "source": [ "### 7.2 OnlineCpdPlotter in plotly\n", "\n", "The same coordination problem exists in plotly, and the plotter solves it in almost the same conceptual way. The difference is only that subplot targets are row-column coordinates instead of matplotlib axes objects.\n", "\n", "Using both backends side by side makes the plotter abstraction especially clear: the provider, trace, and ground-truth information stay the same, while only the rendering target changes.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "1d8c5a71", "metadata": {}, "outputs": [], "source": [ "plotter_go = OnlineCpdPlotter(\n", " backend=DrawBackend.PLOTLY,\n", " data_provider=visualization_provider,\n", " detection_trace=visualization_trace,\n", ")\n", "plotter_go.set_ground_truth(list(visualization_provider.change_points), margin=manual_margin)\n", "plotter_go.set_legend_axis(\"detection_function\")\n", "fig = make_subplots(\n", " rows=3,\n", " cols=1,\n", " shared_xaxes=True,\n", " vertical_spacing=0.08,\n", " subplot_titles=(\"Time Series with Change Points\", \"Detection Function\", \"Processing Time per Step\"),\n", ")\n", "fig = plotter_go.draw(\n", " figure=fig,\n", " axes={\"timeseries\": (1, 1), \"detection_function\": (2, 1), \"processing_time\": (3, 1)},\n", ")\n", "fig.update_layout(title=\"OnlineCpdPlotter (plotly)\", height=900, showlegend=True)\n", "fig" ] }, { "cell_type": "markdown", "id": "45101baf-a4b0-4d57-abf0-8b064935c893", "metadata": {}, "source": [ "### 7.3 OnlineCpdPlotter with PlainMultivariateTimeseriesVisualizer and automatic vertical layout\n", "\n", "The plotter can now coordinate a multivariate timeseries visualizer directly. In the plain multivariate case, the automatic `vertical` layout creates one row per timeseries axis and then appends the trace panels below them.\n", "\n", "That makes the coordinator useful even when the data side is multivariate but the detection trace still comes from one selected signal or derived univariate view.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "e2f73fa7-e1ff-43ca-8700-3800996449fd", "metadata": {}, "outputs": [], "source": [ "plain_plotter_visualizer = PlainMultivariateTimeseriesVisualizer(\n", " DrawBackend.MATPLOTLIB,\n", " dimensionality=3,\n", ")\n", "plain_plotter_visualizer.set_data_provider(multivariate_plain_provider)\n", "plain_plotter_visualizer.set_plot_opts(axes=[0, 1, 2], xlabel=\"Sample Index\", grid=True)\n", "plain_plotter_visualizer.set_plot_opts(axes=[0], title=\"Sensor A\", ylabel=\"Sensor A\")\n", "plain_plotter_visualizer.set_plot_opts(axes=[1], title=\"Sensor B\", ylabel=\"Sensor B\")\n", "plain_plotter_visualizer.set_plot_opts(axes=[2], title=\"Load\", ylabel=\"Load\")\n", "plain_plotter_visualizer.set_draw_opts(axes=[0], color=\"tab:blue\", label=\"sensor_a\")\n", "plain_plotter_visualizer.set_draw_opts(axes=[1], color=\"tab:orange\", label=\"sensor_b\")\n", "plain_plotter_visualizer.set_draw_opts(axes=[2], color=\"tab:green\", label=\"load\")\n", "\n", "multivariate_plotter = OnlineCpdPlotter(\n", " backend=DrawBackend.MATPLOTLIB,\n", " data_provider=multivariate_plain_provider,\n", " detection_trace=multivariate_visualization_trace,\n", " layout=\"vertical\",\n", ")\n", "multivariate_plotter.set_timeseries_visualizer(plain_plotter_visualizer)\n", "multivariate_plotter.set_ground_truth(list(multivariate_plain_provider.change_points), margin=manual_margin)\n", "multivariate_plotter.set_legend_axis(\"detection_function\")\n", "fig, ax_mapping = multivariate_plotter.default_layout()\n", "fig.suptitle(\"OnlineCpdPlotter with plain multivariate visualizer (automatic vertical layout)\", fontsize=16)\n", "fig = multivariate_plotter.draw(figure=fig, axes=ax_mapping)\n", "for ax in ax_mapping.values():\n", " handles, labels = ax.get_legend_handles_labels()\n", " if labels:\n", " ax.legend(loc=\"upper left\", fontsize=9)\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "e7ec7fd4-7319-4441-a2da-728879dd50c4", "metadata": {}, "source": [ "### 7.4 OnlineCpdPlotter with RichMultivariateTimeseriesVisualizer and automatic left-right layout\n", "\n", "The automatic `split` layout now also works with the rich multivariate visualizer. The logical multivariate plots are placed in the left column, while the detection trace panels stay in the right column.\n", "\n", "This is a useful default when the multivariate signal structure is the main story and the trace should stay visible beside it rather than below it.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "6c396fbf-b8eb-4851-bad5-8d90ef95625e", "metadata": {}, "outputs": [], "source": [ "rich_plotter_visualizer = RichMultivariateTimeseriesVisualizer(DrawBackend.PLOTLY)\n", "rich_plotter_visualizer.set_data_provider(multivariate_pandas_provider_with_time)\n", "rich_plotter_visualizer.set_time_column(\"elapsed_seconds\")\n", "rich_plotter_visualizer.define_plot(\n", " \"signals\",\n", " title=\"Signals\",\n", " xlabel=\"Elapsed Seconds\",\n", " ylabel=\"Sensor Value\",\n", " grid=True,\n", ")\n", "rich_plotter_visualizer.define_plot(\n", " \"operations\",\n", " title=\"Operations\",\n", " xlabel=\"Elapsed Seconds\",\n", " ylabel=\"Sensor Value\",\n", " ylabel_twin=\"Load\",\n", " grid=True,\n", ")\n", "rich_plotter_visualizer.add_series(\"signals\", \"sensor_a\", column=\"sensor_a\", color=\"tab:blue\")\n", "rich_plotter_visualizer.add_series(\"signals\", \"sensor_b\", column=\"sensor_b\", color=\"tab:orange\")\n", "rich_plotter_visualizer.add_series(\"operations\", \"sensor_a\", column=\"sensor_a\", color=\"tab:blue\")\n", "rich_plotter_visualizer.add_series(\n", " \"operations\",\n", " \"load\",\n", " column=\"load\",\n", " twin=True,\n", " color=\"tab:green\",\n", " linestyle=\"dash\",\n", ")\n", "\n", "rich_multivariate_plotter = OnlineCpdPlotter(\n", " backend=DrawBackend.PLOTLY,\n", " data_provider=multivariate_pandas_provider_with_time,\n", " detection_trace=multivariate_visualization_trace,\n", " layout=\"split\",\n", ")\n", "rich_multivariate_plotter.set_timeseries_visualizer(rich_plotter_visualizer)\n", "rich_multivariate_plotter.set_ground_truth(list(multivariate_pandas_provider.change_points), margin=manual_margin)\n", "for component_name in (\n", " \"ground_truth_lines\",\n", " \"margin_fill\",\n", " \"detected_lines\",\n", " \"forced_lines\",\n", " \"learning_fill\",\n", " \"skip_fill\",\n", "):\n", " rich_multivariate_plotter.remove_component(component_name)\n", "\n", "elapsed_seconds = multivariate_pandas_provider_with_time.dataset()[\"elapsed_seconds\"].tolist()\n", "raw_change_points = list(multivariate_pandas_provider.change_points)\n", "raw_detected_points = list(multivariate_visualization_trace.signal_change_points)\n", "raw_learning_periods = list(multivariate_visualization_trace.learning_periods)\n", "raw_skip_periods = list(multivariate_visualization_trace.skip_periods)\n", "\n", "\n", "def to_elapsed_point(index: int) -> float:\n", " return float(elapsed_seconds[index])\n", "\n", "\n", "def to_elapsed_region(start: int, stop: int) -> tuple[float, float]:\n", " return float(elapsed_seconds[start]), float(elapsed_seconds[stop])\n", "\n", "\n", "elapsed_ground_truth_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines([to_elapsed_point(cp) for cp in raw_change_points])\n", " .set_style(color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Ground Truth\", legend=True)\n", ")\n", "elapsed_detected_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines([to_elapsed_point(cp) for cp in raw_detected_points])\n", " .set_style(color=\"green\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Detected CP\", legend=True)\n", ")\n", "elapsed_margin_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions([to_elapsed_region(cp - manual_margin, cp + manual_margin) for cp in raw_change_points])\n", " .set_style(fill_color=\"red\", fill_alpha=0.1, label=\"Margin Window\", legend=True)\n", ")\n", "elapsed_learning_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions([to_elapsed_region(start, stop) for start, stop in raw_learning_periods])\n", " .set_style(fill_color=\"orange\", fill_alpha=0.12, label=\"Learning Period\", legend=True)\n", ")\n", "elapsed_skip_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions([to_elapsed_region(start, stop) for start, stop in raw_skip_periods])\n", " .set_style(fill_color=\"green\", fill_alpha=0.10, label=\"Skip Period\", legend=True)\n", ")\n", "\n", "index_ground_truth_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines(raw_change_points)\n", " .set_style(color=\"red\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Ground Truth\", legend=True)\n", ")\n", "index_detected_go = (\n", " VerticalLineVisualComponent(DrawBackend.PLOTLY)\n", " .set_lines(raw_detected_points)\n", " .set_style(color=\"green\", linestyle=\"dash\", linewidth=2, alpha=0.8, label=\"Detected CP\", legend=True)\n", ")\n", "index_learning_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions(raw_learning_periods)\n", " .set_style(fill_color=\"orange\", fill_alpha=0.12, label=\"Learning Period\", legend=True)\n", ")\n", "index_skip_go = (\n", " VerticalFillComponent(DrawBackend.PLOTLY)\n", " .set_regions(raw_skip_periods)\n", " .set_style(fill_color=\"green\", fill_alpha=0.10, label=\"Skip Period\", legend=True)\n", ")\n", "\n", "fig, ax_mapping = rich_multivariate_plotter.default_layout()\n", "fig = rich_multivariate_plotter.draw(figure=fig, axes=ax_mapping)\n", "rich_timeseries_axes = rich_plotter_visualizer.ordered_axes\n", "trace_axes = [\"detection_function\", \"processing_time\"]\n", "trace_axes.extend(sorted(rich_multivariate_plotter.trace_visualizer.state_visualizer.axes))\n", "trace_axes = [axis_name for axis_name in trace_axes if axis_name in ax_mapping]\n", "\n", "for component in (\n", " elapsed_ground_truth_go,\n", " elapsed_detected_go,\n", " elapsed_margin_go,\n", " elapsed_learning_go,\n", " elapsed_skip_go,\n", "):\n", " for index, axis_name in enumerate(rich_timeseries_axes):\n", " component.draw(fig, ax_mapping[axis_name], add_legend=index == 0 and component is not elapsed_detected_go)\n", "\n", "index_ground_truth_go.draw(fig, ax_mapping[\"detection_function\"], add_legend=False)\n", "index_detected_go.draw(fig, ax_mapping[\"detection_function\"], add_legend=True)\n", "for component in (index_learning_go, index_skip_go):\n", " for axis_name in trace_axes:\n", " component.draw(fig, ax_mapping[axis_name], add_legend=axis_name == \"processing_time\")\n", "fig.update_layout(\n", " title=\"OnlineCpdPlotter with rich multivariate visualizer (automatic left-right layout)\",\n", " height=1100,\n", " showlegend=True,\n", ")\n", "fig" ] }, { "cell_type": "markdown", "id": "733abe91", "metadata": {}, "source": [ "**Summary of Section 7:** `OnlineCpdPlotter` is the coordinator layer of the online visualization API. It bundles sensible defaults and consistent draw ordering so repeated diagnostic plots become easier to produce and less error-prone.\n" ] }, { "cell_type": "markdown", "id": "c53910c4", "metadata": {}, "source": [ "## Section 8. Single-Run and Multiple-Run Metrics\n", "\n", "Visualization often sits next to metric computation. A figure answers \u201cwhat happened here?\u201d, while a metric answers \u201chow should this run, or collection of runs, be summarized?\u201d This section gives the minimal metric context needed to connect visual traces to the benchmarking notebooks that follow.\n", "\n", "The important distinction is scope. A single-run metric evaluates one `SingleRun`: one trace paired with one labeled provider. A multiple-run metric evaluates a sequence of those runs and aggregates or derives a result across the collection. The reset and no-reset benchmarking notebooks build on this same split, but add orchestration, registries, and scenario-specific tables." ] }, { "cell_type": "code", "execution_count": null, "id": "b4736d52-09e5-46d7-ba73-e1d55fb1659a", "metadata": {}, "outputs": [], "source": [ "metric_run = SingleRun(trace=visualization_trace, provider=visualization_provider)\n", "metric_margin = (0, 12)\n", "\n", "single_run_metric_values = {\n", " \"true_positives\": TruePositiveCount(error_margin=metric_margin).evaluate(metric_run),\n", " \"false_positives\": FalsePositiveCount(error_margin=metric_margin).evaluate(metric_run),\n", " \"false_negatives\": FalseNegativeCount(error_margin=metric_margin).evaluate(metric_run),\n", " \"delays\": Delays(max_delay=metric_margin[1]).evaluate(metric_run),\n", " \"run_lengths\": RunLengths().evaluate(metric_run),\n", "}\n", "print(single_run_metric_values)\n", "\n", "metric_runs = [metric_run]\n", "multiple_run_metric_values = {\n", " \"classification_report\": ClassificationReport(error_margin=metric_margin).evaluate(metric_runs),\n", " \"mean_delay\": MeanDelayMetric(max_delay=metric_margin[1]).evaluate(metric_runs),\n", " \"median_delay\": MedianDelayMetric(max_delay=metric_margin[1]).evaluate(metric_runs),\n", " \"arl\": ARLMetric().evaluate(metric_runs),\n", "}\n", "display(pd.DataFrame([multiple_run_metric_values]).T.rename(columns={0: \"value\"}))" ] }, { "cell_type": "markdown", "id": "single-run-metrics-summary", "metadata": {}, "source": [ "### 8.1 Single-run metrics\n", "\n", "The code above uses the same trace and provider that the plotting examples used. That is the key idea: a `SingleRun` is just the pairing of those two objects. Classification primitives compare detected change points with provider ground truth under an error margin, while online metrics such as `Delays` and `RunLengths` inspect event timing.\n", "\n", "These values are useful for debugging one figure. If the plot looks surprising, the single-run metrics give a compact numerical summary of the same run.\n" ] }, { "cell_type": "markdown", "id": "multiple-run-metrics-summary", "metadata": {}, "source": [ "### 8.2 Multiple-run metrics\n", "\n", "Multiple-run metrics accept a sequence of `SingleRun` objects. In this notebook we pass one run only so the API shape stays visible without reintroducing a full benchmark campaign. In real evaluation, the sequence usually comes from a benchmark registry over many providers or detector configurations.\n", "\n", "`ClassificationReport` is a derived metric: it combines TP, FP, FN, precision, recall, and F1 into one dictionary. `MeanDelayMetric`, `MedianDelayMetric`, and `ARLMetric` are aggregation metrics: they evaluate a single-run timing metric on each run and summarize the collection.\n", "\n", "**Summary of Section 8:** Metrics and visualizations are two views of the same execution record. A trace can be drawn for interpretation, wrapped into a `SingleRun` for local metrics, or collected with other runs for benchmark-level summaries.\n" ] }, { "cell_type": "markdown", "id": "visualization-final-recap", "metadata": {}, "source": [ "## Section 9. Final Recap\n", "\n", "This chapter built the visualization story around one provider and one online trace. We started with manual matplotlib and plotly figures, moved reusable annotations into visual components, used direct visualizers for semantic panels, added both plain and rich multivariate visualizers, coordinated full figures through `OnlineCpdPlotter`, and finally connected the same trace to single-run and multiple-run metric APIs.\n", "\n", "The next notebooks use these ideas in benchmark settings. Reset benchmarking turns configured detectors into whole-run event-sequence metrics, while no-reset benchmarking keeps continuous traces and interprets them under threshold and policy choices.\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.13" }, "mystnb": { "execution_mode": "force" } }, "nbformat": 4, "nbformat_minor": 5 }