Multidimensional Angular Model (RGB visualization)

Research provenance

This notebook accompanies Multi-sensory Integration in a Quantum-Like Robot Perception Model, published in Experimental Robotics (2021); the preprint is arXiv:2006.16404. Its RGB visualizations are paper-specific and intentionally remain in this notebook.

import matplotlib.pyplot as plt
import numpy as np

from qrobot.bursts import ZeroBurst
from qrobot.models import AngularModel

In this notebook we present a 3-dimensional (\(n=3\)) demo for the AngularModel class. For simplicity, we consider \(\tau = 1\).

n = 3
tau = 1

Note: The RGB analogy provides an intuitive picture of three normalized sensor channels; it is a visualization device, not a claim that the model learns a color concept.

Input definition

We start by defining an arbitrary normalized three-channel input:

input_data = [0.1, 0.5, 0.9]

Hide code cell source

def plot_rgb_input(data):
    """Plot a normalized three-channel input in RGB space and as a color."""
    fig = plt.figure(figsize=(10, 3))
    rgb_axis = fig.add_subplot(1, 2, 1, projection="3d")
    rgb_axis.scatter(*data, color=[data], marker="o", s=80)
    rgb_axis.set(
        xlabel="Red",
        ylabel="Green",
        zlabel="Blue",
        xlim=(0, 1),
        ylim=(0, 1),
        zlim=(0, 1),
    )

    color_axis = fig.add_subplot(1, 2, 2)
    color_axis.imshow(np.asarray(data)[np.newaxis, np.newaxis, :])
    color_axis.set_title(f"RGB input = {data}")
    color_axis.set_axis_off()
    plt.show()
plot_rgb_input(input_data)
../_images/49fecf1ba92b27d2b0609ac766fe8643f158c3b1a309ffcf1454e6a229f7ff33.png

Encode the input in the model

We initialize the model by instantiating an object with \(n\) and \(\tau\)

model = AngularModel(n, tau)

Encoding a general multidimensional sequence requires one call per temporal sample. The model maps each vector element to its corresponding dimension. This example has \(\tau = 1\), so the single event requires one encoding call:

model.clear()  # Keep this cell repeatable by discarding any earlier encoding.
model.encode_vector(input_data)
[0.3141592653589793, 1.5707963267948966, 2.827433388230814]

The model is implemented by a Qiskit quantum circuit:

model.print_circuit()
      ┌──────────┐
q_0: ─┤ Ry(π/10) ├
      ├─────────┬┘
q_1: ─┤ Ry(π/2) ├─
     ┌┴─────────┴┐
q_2: ┤ Ry(9π/10) ├
     └───────────┘

Given the input we defined above, the model is in the following state:

Hide code cell source

def plot_encoded_model_state(encoded_model):
    """Plot the encoded statevector and density matrix."""
    encoded_model.plot_state_mat()
plot_encoded_model_state(model)
../_images/7abcf59dbb7506a84f3397689c6ee8ef8747bc38e2f868ad6fd58feef22485ca.png

Measurement simulation

We simulate shots measurements, and then we extract the relative frequencies for the \(2^n\) possible basis state outcomes:

shots = 1000000
counts = model.measure(shots)

Raw counts for each possible outcome:

import json

print("Aggregated binary outcomes of the circuit:")
print(json.dumps(counts, sort_keys=True, indent=4))
Aggregated binary outcomes of the circuit:
{
    "000": 11907,
    "001": 282,
    "010": 12001,
    "011": 336,
    "100": 476141,
    "101": 11811,
    "110": 475652,
    "111": 11870
}

From the raw counts we can obtain the relative frequencies (aka the probabilities) and compare them with the input sequence. Since we have not operated any change of basis (with the model.query(target) method), the canonical basis is maintained, and it is possible to visualize it still with our RGB representation:

Hide code cell source

def plot_canonical_measurements(data, measurement_counts, measurement_shots):
    """Plot the input and canonical-basis outcome probabilities as RGB tiles."""
    states = [f"{index:03b}" for index in range(8)]
    fig, axes = plt.subplots(1, 9, figsize=(15, 2), dpi=150)

    tiles = [("Input", data, "")] + [
        (
            f"|{state}⟩",
            [float(bit) for bit in reversed(state)],
            f"{measurement_counts.get(state, 0) / measurement_shots:.1%}",
        )
        for state in states
    ]
    for axis, (title, color, probability) in zip(axes, tiles, strict=True):
        axis.imshow(np.asarray(color)[np.newaxis, np.newaxis, :])
        axis.set_title(title)
        axis.set_xlabel(probability)
        axis.set_xticks([])
        axis.set_yticks([])
    plt.show()
plot_canonical_measurements(input_data, counts, shots)
../_images/2fac45020fb5610cb7f16bea8b2ae568e45830b8c8b948acd01d0977099db65c.png

This shows how the two states which are more likely to be measured are the “blue” \(|100\rangle\) and “cyan” \(|110\rangle\) ones, which are the closes to our “blue-ish” input.

Measurement changes w.r.t. varying input

We can now move the input through RGB space and observe how the probability of each canonical basis state changes. We define this set of inputs:

input_positions = np.linspace(0, 1, 101)
path_positions = np.linspace(0, 1, 6)
path_colors = np.asarray(
    [
        [0.0, 0.0, 0.0],  # black
        [0.2, 0.9, 0.1],  # green-ish
        [1.0, 1.0, 1.0],  # white
        [0.9, 0.2, 0.1],  # red-ish
        [0.2, 0.1, 0.9],  # blue-ish
        [0.0, 0.0, 0.0],  # black
    ]
)
input_colors = np.column_stack(
    [np.interp(input_positions, path_positions, path_colors[:, channel]) for channel in range(3)]
)

Hide code cell source

def plot_input_color_path(colors, stops):
    """Render the complete RGB input path as a standalone color strip."""
    fig, axis = plt.subplots(figsize=(15, 2), dpi=150)
    axis.imshow(
        np.asarray(colors)[np.newaxis, :, :],
        aspect="auto",
        extent=(0, 1, 0, 1),
        interpolation="bilinear",
    )
    axis.set_xticks(
        stops,
        ["black", "green-ish", "white", "red-ish", "blue-ish", "black"],
    )
    axis.set_yticks([])
    axis.set_title("RGB input path")
    axis.set_xlabel("Position along the input path")
    plt.show()
plot_input_color_path(input_colors, path_positions)
../_images/3e27e6cc09da1cb0fa572f7f95ba22a2fb2b8446556fc6a36ab8de26cf5fbadd.png

The color strip renders the input at every position. We now want to see how the probabilities of measuring each of the eight canonical basis states changes with the input. For every point on the input domain, we create a fresh model, encode its three RGB components, and read the exact probability of every basis state from the statevector:

state_labels = [f"{index:03b}" for index in range(8)]
canonical_probabilities = {state: [] for state in state_labels}
state_rgb_colors = {state: tuple(float(bit) for bit in reversed(state)) for state in state_labels}

for input_color in input_colors:
    sliding_model = AngularModel(n=3, tau=1)
    for dim, value in enumerate(input_color):
        sliding_model.encode(value, dim)
    probabilities = np.abs(sliding_model.get_statevector()) ** 2
    for index, state in enumerate(state_labels):
        canonical_probabilities[state].append(probabilities[index])

Hide code cell source

import matplotlib.patheffects as path_effects


def plot_canonical_input_sweep(positions, colors, state_probabilities, state_colors):
    """Plot an RGB input path in the unqueried canonical basis."""
    linestyles = ["-", "-", "-", "-", "-", "-", "-", "-"]
    markers = ["o", "s", "^", "v", "D", "P", "X", "*"]
    fig, axis = plt.subplots(figsize=(13, 10))
    for index, state in enumerate(state_labels):
        (line,) = axis.plot(
            positions,
            state_probabilities[state],
            color=state_colors[state],
            linestyle=linestyles[index],
            marker=markers[index],
            markevery=(index * 2, 18),
            markersize=9,
            linewidth=2,
            alpha=0.5,
            zorder=2 + index,
            label=f"|{state}⟩",
        )
        if state in {"000", "111"}:
            outline = "white" if state == "000" else "black"
            line.set_path_effects(
                [
                    path_effects.Stroke(linewidth=0.5, foreground=outline),
                    path_effects.Normal(),
                ]
            )

    input_gradient = np.asarray(colors)[np.newaxis, :, :]
    axis.imshow(
        input_gradient,
        aspect="auto",
        extent=(0, 1, -0.20, -0.10),
        transform=axis.get_xaxis_transform(),
        clip_on=False,
        interpolation="bilinear",
    )
    axis.set(
        xlabel="Position along the RGB input path",
        ylabel="Exact state probability",
        xlim=(0, 1),
        ylim=(0, 1),
        title="Canonical-basis response along an RGB input path",
    )
    axis.xaxis.labelpad = 35
    axis.set_xticks(
        np.linspace(0, 1, 6),
        ["black", "green-ish", "white", "red-ish", "blue-ish", "black"],
    )
    axis.tick_params(axis="x", pad=35)
    axis.grid(alpha=0.3)
    axis.legend(ncols=4)
    fig.subplots_adjust(bottom=0.25)
    plt.show()
plot_canonical_input_sweep(input_positions, input_colors, canonical_probabilities, state_rgb_colors)
../_images/56d23e6bc778ead836d3e6eb3ccc0fd43056aa688c81e2dc9b8d7eb633a93d56.png

As the input reaches each color stop, probability concentrates on the nearest canonical RGB corner: \(|000\rangle\) at black, \(|010\rangle\) near green, \(|111\rangle\) at white, \(|001\rangle\) near red, and \(|100\rangle\) near blue. Between those stops, several basis states remain probable because each channel is encoded continuously rather than rounded to 0 or 1. Some curves overlap when different states receive the same probability; their distinct markers make those coincident outcomes visible.

From an RGB path to the whole input cube

The path above visits only a thin slice of the input domain. Does the same behavior hold away from that path? We now reproduce the two whole-RGB-space visualizations from the study cited above: the three probability cubes and the overlapping probability clouds plotted against distance from black. We still use \(n=3\), \(\tau=1\), and the canonical basis, with no query applied.

For one channel, encoding \(x_i\) prepares \(\cos(\pi x_i/2)|0\rangle+\sin(\pi x_i/2)|1\rangle\). Since these three qubits are independent, the probability of a complete bit string is a product:

\[\begin{split} P(s\mid\mathbf{x})=\prod_{i=0}^{2} \begin{cases} \cos^2(\pi x_i/2), & s_i=0,\\ \sin^2(\pi x_i/2), & s_i=1. \end{cases} \end{split}\]

Here \(x_0,x_1,x_2\) are red, green, and blue. Qiskit displays the bits as \(s_2s_1s_0\), so the red corner is \(|001\rangle\). We evaluate this expression in a batch instead of simulating a million shots for every input. This gives the exact probabilities underlying the paper’s sampled estimates.

rgb_levels = np.arange(0, 256, 5)
rgb_grid = np.stack(
    np.meshgrid(rgb_levels, rgb_levels, rgb_levels, indexing="ij"), axis=-1
).reshape(-1, 3)
normalized_rgb_grid = rgb_grid / 255.0


def rgb_basis_probabilities(colors):
    """Exact tau=1 probabilities, in Qiskit's |blue green red> order."""
    colors = np.asarray(colors)
    probability_zero = np.cos(np.pi * colors / 2) ** 2
    probability_one = np.sin(np.pi * colors / 2) ** 2
    bits = (np.arange(8)[:, None] >> np.arange(3)) & 1
    return np.prod(
        np.where(bits[None, :, :], probability_one[:, None, :], probability_zero[:, None, :]),
        axis=-1,
    )


rgb_probabilities = rgb_basis_probabilities(normalized_rgb_grid)
print(f"{len(rgb_levels)} levels per channel; {len(rgb_grid):,} RGB inputs")

# Check the formula against actual circuits, including every RGB corner.
check_colors = np.vstack(
    (
        [[float(bit) for bit in reversed(state)] for state in state_labels],
        [input_data, [0.5, 0.5, 0.5], [0.23, 0.61, 0.87]],
    )
)
for color, expected in zip(check_colors, rgb_basis_probabilities(check_colors), strict=True):
    check_model = AngularModel(n=3, tau=1)
    check_model.encode_vector(color)
    np.testing.assert_allclose(expected, np.abs(check_model.get_statevector()) ** 2, atol=1e-14)
np.testing.assert_allclose(rgb_probabilities.sum(axis=1), 1, atol=1e-14)
52 levels per channel; 140,608 RGB inputs

We include both 0 and 255: a step of 5 gives 52 levels and 140,608 inputs. The paper reports 132,651 alongside a step-of-5 description; that count does not match the inclusive grid used here. The axes retain the original 0–255 RGB units, while the model receives normalized values.

Probability landscapes at three canonical corners

Each point’s position is an RGB input, but its heat color is the probability of the outcome named above that cube. The common scale runs from black (zero) to white (one); it does not depict the input’s own RGB color. Blue rings mark the corresponding canonical corners, as in the original figure.

Hide code cell source

def plot_rgb_probability_cubes(points, probabilities):
    """Recreate the ISER probability cubes on the complete step-5 grid."""
    fig = plt.figure(figsize=(16, 5.4), layout="constrained")
    axes = []
    for panel, state_index in enumerate([0, 1, 7], start=1):
        axis = fig.add_subplot(1, 3, panel, projection="3d")
        axes.append(axis)
        cloud = axis.scatter(
            *points.T,
            c=probabilities[:, state_index],
            cmap="hot",
            vmin=0,
            vmax=1,
            s=9,
            linewidths=0,
            depthshade=False,
            rasterized=True,
        )
        corner = 255 * ((state_index >> np.arange(3)) & 1)
        axis.scatter(
            *corner,
            s=240,
            facecolors="none",
            edgecolors="blue",
            linewidths=3,
            depthshade=False,
            zorder=10,
        )
        axis.set(
            title=f"|{state_index:03b}⟩ probability",
            xlabel="Red",
            ylabel="Green",
            zlabel="",
            xlim=(0, 255),
            ylim=(0, 255),
            zlim=(0, 255),
            xticks=[0, 128, 255],
            yticks=[0, 128, 255],
            zticks=[0, 128, 255],
        )
        axis.text2D(0.92, 0.60, "Blue", transform=axis.transAxes)
        axis.set_box_aspect((1, 1, 1))
        axis.view_init(elev=25, azim=-60)
    fig.colorbar(cloud, ax=axes, shrink=0.7, pad=0.04, label="Exact probability")
    plt.show()
plot_rgb_probability_cubes(rgb_grid, rgb_probabilities)
../_images/115251c6756e97a46de8c662c09a921a496bc2fdfa34d328a67e8e068c18f026.png

Probability concentrates near black for \(|000\rangle\), red for \(|001\rangle\), and white for \(|111\rangle\). At each marked corner the corresponding outcome is certain. Moving away changes the three channel factors continuously. These are dense point clouds: the visible outer faces occlude interior points. The next plot exposes the entire grid in a different projection.

The probability clouds against distance from black

We now collapse the input coordinates to \(d=\|\mathrm{RGB}-(0,0,0)\|_2\), from 0 to \(255\sqrt{3}\approx441.7\). For each input, we retain four probabilities: all three zeros, exactly two zeros, exactly one zero, and no zeros. A group’s probability is the sum of its member-state probabilities, not their average:

\[ P_{2\,\mathrm{zeros}}=P_{001}+P_{010}+P_{100},\qquad P_{1\,\mathrm{zero}}=P_{011}+P_{101}+P_{110}. \]
rgb_distances = np.linalg.norm(rgb_grid, axis=1)
zero_counts = np.asarray([state.count("0") for state in state_labels])
probabilities_by_zeros = {
    count: rgb_probabilities[:, zero_counts == count].sum(axis=1) for count in range(4)
}
np.testing.assert_allclose(sum(probabilities_by_zeros.values()), 1, atol=1e-14)

Hide code cell source

def plot_rgb_distance_clouds(distances, grouped_probabilities):
    """Recreate the four overlapping ISER clouds without distance binning."""
    fig, axis = plt.subplots(figsize=(13, 7), layout="constrained")
    for count, color, label in [
        (0, "red", "|111⟩ (0 zeros)"),
        (1, "green", "1 zero: P011 + P101 + P110"),
        (2, "#b5b500", "2 zeros: P001 + P010 + P100"),
        (3, "blue", "|000⟩ (3 zeros)"),
    ]:
        axis.scatter(
            distances,
            grouped_probabilities[count],
            color=color,
            s=36,
            alpha=0.025,
            edgecolors="none",
            rasterized=True,
        )
        # Opaque legend samples stay readable despite the faint cloud points.
        axis.plot([], [], "o", color=color, label=label)
    axis.set(
        xlabel="Euclidean distance from black (RGB units)",
        ylabel="Exact probability of the outcome group",
        title="Whole RGB space: canonical outcomes grouped by zero count",
        xlim=(-5, 255 * np.sqrt(3) + 5),
        ylim=(-0.02, 1.02),
    )
    axis.grid(alpha=0.15)
    axis.legend(loc="center right", fontsize="small")
    plt.show()
plot_rgb_distance_clouds(rgb_distances, probabilities_by_zeros)
../_images/69b8590121c23d2a7c81729eb330afa0bc3c75293f56808c5a2df43f87e9cb76.png

The overlapping fans are the result of projecting a three-dimensional grid onto one distance axis. Every input contributes one point to each group; no curve fitting, averaging, or distance binning is applied. Their fine bands come from the regular RGB sampling, and opacity makes overlapping points appear darker.

Distance alone does not determine the probabilities. For example, normalized inputs \((1,0,0)\) and \((1/\sqrt{3},1/\sqrt{3},1/\sqrt{3})\) are both 255 RGB units from black. The first has \(P_{000}=0\), while the second has \(P_{000}=\cos^6(\pi/(2\sqrt{3}))\approx0.055\). The model responds to the individual channel differences, not just their Euclidean norm. This explains why these are clouds rather than single-valued confidence curves.

Black produces three zeros with certainty; a primary-color corner produces two, a secondary-color corner one, and white none. Intermediate inputs spread probability across groups. Later, ZeroBurst assigns these groups intensities \(1\), \(2/3\), \(1/3\), and \(0\), respectively. First, we need to move the reference from black to an arbitrary query color.

Querying for similarity to a color

The whole-cube plots used the canonical zero state (black) as the reference. To compare against another color, keep a warm-red input fixed and use a blue query:

fixed_input = [0.9, 0.2, 0.1]
fixed_query = [0.2, 0.2, 0.7]

Hide code cell source

def plot_fixed_input_and_query(input_color, query_color):
    """Display the fixed RGB input and query as color swatches."""
    fig, axes = plt.subplots(1, 9, figsize=(15, 2), dpi=150)
    for axis, title, color in zip(
        axes[[0, 2]],
        (f"Input {input_color}", f"Query {query_color}"),
        (input_color, query_color),
        strict=True,
    ):
        axis.imshow(np.asarray(color)[np.newaxis, np.newaxis, :])
        axis.set_title(title)
    for axis in axes:
        axis.set_axis_off()
    plt.show()
plot_fixed_input_and_query(fixed_input, fixed_query)
../_images/75831e4ba1e1bce3d44d06256abe407643502a0396363368264924801142d4ad.png

A query changes the basis in which the encoded input is measured. Given a normalized target color \(\bar{\mathbf{x}}\), it applies an inverse rotation to every qubit:

\[ Q(\bar{\mathbf{x}})=\bigotimes_i R_y(-\pi\bar{x}_i). \]

This maps an input equal to the query onto \(|000\rangle\). After this basis change, the state vectors can no longer be mapped to an RGB color. For each channel, alignment makes the 0 outcome more probable and mismatch makes the 1 outcome more probable. After measuring a state, ZeroBurst converts its zero-bit fraction into a match intensity: how closely the sampled outcome matches the query.

\[ B_0(s)=\frac{\text{number of zeros in }s}{3}. \]

OneBurst provides the complementary mismatch intensity: the sampled outcome’s fraction of bits that differ from the query-mapped zero state.

The next cell encodes a fixed input, applies the query transformation, and samples the queried model:

queried_model = AngularModel(n=3, tau=1)
for dim, value in enumerate(fixed_input):
    queried_model.encode(value, dim)
queried_model.query(fixed_query)

query_shots = 100_000
queried_counts = queried_model.measure(query_shots)

Hide code cell source

def plot_queried_measurements(query_color, measurement_counts, measurement_shots):
    """Plot queried outcomes using copper intensity for ZeroBurst values."""
    heat_map = plt.colormaps["copper"]
    states = [f"{index:03b}" for index in range(8)]
    fig, axes = plt.subplots(1, 9, figsize=(15, 2), dpi=150)

    axes[0].imshow(np.asarray(query_color)[np.newaxis, np.newaxis, :])
    axes[0].set_title("Query")
    axes[0].set_xlabel(str(query_color))
    axes[0].set_xticks([])
    axes[0].set_yticks([])

    for axis, state in zip(axes[1:], states, strict=True):
        burst = ZeroBurst()(state)
        probability = measurement_counts.get(state, 0) / measurement_shots
        axis.imshow(np.asarray(heat_map(burst))[np.newaxis, np.newaxis, :])
        axis.set_title(f"|{state}⟩")
        axis.set_xlabel(f"P={probability:.1%}\nB₀={burst:.2f}")
        axis.set_xticks([])
        axis.set_yticks([])
    plt.show()
plot_queried_measurements(fixed_query, queried_counts, query_shots)
../_images/3952dfeb8cac5ba50d132835bf532894b5fef9c9156f5791f427d799ad9ec589.png

The tile color now represents “how much the input is similar to my query” (that is, the ZeroBurst value). With the color code we used here, darker copper means fewer matching channels, and lighter copper means more. The probability \(P\) answers “how likely is this pattern?”, while \(B_0\) answers “what match intensity is emitted if this pattern is sampled?”

Measurement changes w.r.t. varying query (with fixed input)

We can now hold the same input constant while moving the query through RGB space. First, recall the fixed warm-red input:

Hide code cell source

def plot_varying_query_input(input_color):
    """Display the fixed input used throughout the query sweep."""
    fig, axes = plt.subplots(1, 9, figsize=(15, 2), dpi=150)
    axis = axes[0]
    axis.imshow(np.asarray(input_color)[np.newaxis, np.newaxis, :])
    axis.set_title(f"Fixed input {input_color}")
    axis.set_axis_off()
    for unused_axis in axes[1:]:
        unused_axis.set_axis_off()
    plt.show()
plot_varying_query_input(fixed_input)
../_images/53bee89164f511b9fb434aaea6fa02382eaa14a046ae3ecdcb9a4ae124221fe2.png

The query follows a piecewise RGB path from blue to red and then from red to green. Pure red, at the middle of the axis, is the closest point on this path to the warm-red input. The blue and green endpoints each disagree with it strongly in two channels.

query_positions = np.linspace(0, 1, 101)

query_colors = []
for position in query_positions:
    if position <= 0.5:
        blend = 2 * position
        query_colors.append([blend, 0.0, 1.0 - blend])  # blue -> red
    else:
        blend = 2 * (position - 0.5)
        query_colors.append([1.0 - blend, blend, 0.0])  # red -> green

queried_probabilities = {state: [] for state in state_labels}
zero_burst_scores = {state: ZeroBurst()(state) for state in state_labels}

Hide code cell source

def plot_query_color_path(colors):
    """Render the blue–red–green query path as a standalone color strip."""
    fig, axis = plt.subplots(figsize=(15, 2), dpi=150)
    axis.imshow(
        np.asarray(colors)[np.newaxis, :, :],
        aspect="auto",
        extent=(0, 1, 0, 1),
        interpolation="bilinear",
    )
    axis.set_xticks([0, 0.5, 1], ["blue", "red", "green"])
    axis.set_yticks([])
    axis.set_title("RGB query path")
    axis.set_xlabel("Position along the query path")
    plt.show()
plot_query_color_path(query_colors)
../_images/b98bab419639172c2a9a7fd851411b4c07b3d1285af5e55f60b8205d3d9810ef.png

At every position we re-encode the unchanged input in a fresh model, apply the current query color, and record the exact statevector probabilities after the basis change:

for query_color in query_colors:
    queried_model = AngularModel(n=3, tau=1)
    for dim, value in enumerate(fixed_input):
        queried_model.encode(value, dim)
    queried_model.query(query_color)
    probabilities = np.abs(queried_model.get_statevector()) ** 2
    for index, state in enumerate(state_labels):
        queried_probabilities[state].append(probabilities[index])

Hide code cell source

def plot_color_query_sweep(positions, colors, fixed_color, state_probabilities, burst_scores):
    """Plot queried outcomes, coloring each state by its ZeroBurst score."""
    heat_map = plt.colormaps["copper"]
    normalization = plt.Normalize(0, 1)
    markers = ["o", "s", "^", "v", "D", "P", "X", "*"]

    fig, axis = plt.subplots(figsize=(13, 10))
    for index, state in enumerate(state_labels):
        score = burst_scores[state]
        axis.plot(
            positions,
            state_probabilities[state],
            color=heat_map(normalization(score)),
            linestyle="-",
            marker=markers[index],
            markevery=(index * 2, 18),
            markersize=9,
            linewidth=2,
            alpha=0.5,
            zorder=2 + index,
            label=f"|{state}⟩  B₀={score:.2f}",
        )

    query_gradient = np.asarray(colors)[np.newaxis, :, :]
    axis.imshow(
        query_gradient,
        aspect="auto",
        extent=(0, 1, -0.20, -0.10),
        transform=axis.get_xaxis_transform(),
        clip_on=False,
        interpolation="bilinear",
    )
    axis.text(
        0.02,
        0.96,
        f"Fixed input: {fixed_color}",
        transform=axis.transAxes,
        va="top",
        color="white",
        fontweight="bold",
        bbox={
            "boxstyle": "round,pad=0.4",
            "facecolor": fixed_color,
            "edgecolor": "white",
        },
    )
    axis.set(
        ylabel="Exact state probability",
        xlim=(0, 1),
        ylim=(0, 1),
        title="Fixed warm-red input queried along a blue–red–green path",
    )
    axis.set_xticks([0, 0.5, 1], ["blue query", "red query", "green query"])
    axis.tick_params(axis="x", pad=55)
    axis.grid(alpha=0.3)
    axis.legend(ncols=3, fontsize="small")

    color_scale = plt.cm.ScalarMappable(norm=normalization, cmap=heat_map)
    color_scale.set_array([])
    fig.colorbar(color_scale, ax=axis, label="ZeroBurst match intensity B₀")
    fig.subplots_adjust(bottom=0.38)
    plt.show()
plot_color_query_sweep(
    query_positions,
    query_colors,
    fixed_input,
    queried_probabilities,
    zero_burst_scores,
)
../_images/6fc9f53ffac6d611681d1dedf42fece4e951623a17c6ab6cf5e1d493b3810724.png

The height of a state curve gives the probability of measuring that state. Its copper heat color gives the ZeroBurst value emitted if that state is sampled. The color therefore describes how strongly that particular outcome matches the query.

Around the red query, states with higher match intensities become more probable. Farther from red, lower-match outcomes become more likely.

We can summarize the complete measurement distribution at every query by treating each possible ZeroBurst value as a random outcome. The mean is the average match intensity expected over repeated measurements, while the standard deviation describes how much individual sampled bursts vary around that mean:

burst_means = []
burst_standard_deviations = []

for position_index in range(len(query_positions)):
    probabilities = np.asarray(
        [queried_probabilities[state][position_index] for state in state_labels]
    )
    burst_values = np.asarray([zero_burst_scores[state] for state in state_labels])
    mean = np.sum(probabilities * burst_values)
    variance = np.sum(probabilities * (burst_values - mean) ** 2)
    burst_means.append(mean)
    burst_standard_deviations.append(np.sqrt(variance))

Hide code cell source

def plot_burst_summary(positions, colors, means, standard_deviations):
    """Plot mean ZeroBurst intensity with a continuous ±1σ band."""
    means = np.asarray(means)
    standard_deviations = np.asarray(standard_deviations)
    lower_bound = np.clip(means - standard_deviations, 0, 1)
    upper_bound = np.clip(means + standard_deviations, 0, 1)
    fig, axis = plt.subplots(figsize=(13, 8))
    axis.fill_between(
        positions,
        lower_bound,
        upper_bound,
        color="peru",
        alpha=0.3,
        label="Mean ± 1 standard deviation",
    )
    axis.plot(positions, lower_bound, color="peru", linewidth=1, alpha=0.8)
    axis.plot(positions, upper_bound, color="peru", linewidth=1, alpha=0.8)
    axis.plot(
        positions,
        means,
        color="saddlebrown",
        linewidth=3,
        label="Mean ZeroBurst intensity",
    )
    query_gradient = np.asarray(colors)[np.newaxis, :, :]
    axis.imshow(
        query_gradient,
        aspect="auto",
        extent=(0, 1, -0.20, -0.10),
        transform=axis.get_xaxis_transform(),
        clip_on=False,
        interpolation="bilinear",
    )
    axis.set(
        ylabel="ZeroBurst match intensity",
        xlim=(0, 1),
        ylim=(0, 1),
        title="Mean and variability of the measured match intensity",
    )
    axis.set_xticks([0, 0.5, 1], ["blue query", "red query", "green query"])
    axis.tick_params(axis="x", pad=55)
    axis.grid(alpha=0.3)
    axis.legend()
    fig.subplots_adjust(bottom=0.3)
    plt.show()
plot_burst_summary(
    query_positions,
    query_colors,
    burst_means,
    burst_standard_deviations,
)
../_images/9a986af575383c4741ed4516033bf23fe14787ec860c136b614c47bad1745a9e.png

In the plot above, the central line shows the overall output, and the shaded band shows one standard deviation on either side. The values are computed from exact state probabilities, so the band represents intrinsic measurement variability rather than finite-shot sampling noise.

References