quantum-study-utils 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+
2
+ """
3
+ Quantum measurement utilities.
4
+
5
+ Provides functions for calculating probabilities,
6
+ analyzing measurement counts, and comparing results
7
+ from quantum simulators and real quantum hardware.
8
+
9
+ Compatible with standard Python, NumPy and Qiskit.
10
+ """
11
+
12
+ from collections.abc import Mapping, Sequence
13
+
14
+ import numpy as np
15
+
16
+
17
+ def calculate_probabilities(
18
+ counts: Mapping[str, int],
19
+ ) -> dict[str, float]:
20
+ """
21
+ Calculate measurement probabilities from shot counts.
22
+
23
+ Args:
24
+ counts: Dictionary mapping bitstrings to shot counts.
25
+
26
+ Returns:
27
+ Dictionary mapping bitstrings to probabilities.
28
+ """
29
+ _validate_counts(counts)
30
+
31
+ total = sum(counts.values())
32
+
33
+ if total == 0:
34
+ return {}
35
+
36
+ return {
37
+ state: count / total
38
+ for state, count in sorted(counts.items())
39
+ }
40
+
41
+
42
+ def calculate_state_probabilities(
43
+ statevector: Sequence[complex],
44
+ ) -> dict[str, float]:
45
+ """
46
+ Calculate theoretical probabilities from a statevector.
47
+
48
+ Uses the Born rule: P(i) = |amplitude_i|^2.
49
+
50
+ Args:
51
+ statevector: Quantum statevector amplitudes.
52
+
53
+ Returns:
54
+ Dictionary mapping computational basis states
55
+ to their theoretical probabilities.
56
+ """
57
+ if hasattr(statevector, "data"):
58
+ statevector = statevector.data
59
+
60
+ amplitudes = np.asarray(statevector, dtype=complex)
61
+
62
+ if amplitudes.ndim != 1:
63
+ raise ValueError("Statevector must be one-dimensional.")
64
+
65
+ dimension = len(amplitudes)
66
+
67
+ if dimension == 0 or dimension & (dimension - 1):
68
+ raise ValueError(
69
+ "Statevector length must be a power of two."
70
+ )
71
+
72
+ norm = np.sum(np.abs(amplitudes) ** 2)
73
+
74
+ if not np.isclose(norm, 1.0):
75
+ raise ValueError(
76
+ f"Statevector is not normalized: {norm:.6f}"
77
+ )
78
+
79
+ num_qubits = dimension.bit_length() - 1
80
+
81
+ probabilities = np.abs(amplitudes) ** 2
82
+
83
+ return {
84
+ format(i, f"0{max(1, num_qubits)}b"): float(p)
85
+ for i, p in enumerate(probabilities)
86
+ }
87
+
88
+
89
+ def calculate_percentages(
90
+ counts: Mapping[str, int],
91
+ ) -> dict[str, float]:
92
+ """
93
+ Convert measurement counts into percentages.
94
+
95
+ Returns:
96
+ Dictionary mapping bitstrings to percentages.
97
+ """
98
+ probabilities = calculate_probabilities(counts)
99
+
100
+ return {
101
+ state: probability * 100
102
+ for state, probability in probabilities.items()
103
+ }
104
+
105
+
106
+ def calculate_expectation_z(
107
+ counts: Mapping[str, int],
108
+ qubit: int = 0,
109
+ ) -> float:
110
+ """
111
+ Calculate the expectation value of Pauli-Z
112
+ for a measured qubit.
113
+
114
+ For Qiskit bitstrings, qubit 0 is the rightmost bit.
115
+
116
+ Args:
117
+ counts: Measurement counts.
118
+ qubit: Qubit index.
119
+
120
+ Returns:
121
+ Expectation value between -1 and +1.
122
+ """
123
+ probabilities = calculate_probabilities(counts)
124
+
125
+ if not probabilities:
126
+ raise ValueError("No measurement shots available.")
127
+
128
+ if qubit < 0:
129
+ raise ValueError("Qubit index must be non-negative.")
130
+
131
+ expectation = 0.0
132
+
133
+ for bitstring, probability in probabilities.items():
134
+
135
+ bits = bitstring.replace(" ", "")
136
+
137
+ if qubit >= len(bits):
138
+ raise ValueError("Qubit index out of range.")
139
+
140
+ bit = bits[-(qubit + 1)]
141
+
142
+ if bit not in ("0", "1"):
143
+ raise ValueError("Invalid measurement bitstring.")
144
+
145
+ eigenvalue = 1 if bit == "0" else -1
146
+
147
+ expectation += eigenvalue * probability
148
+
149
+ return expectation
150
+
151
+
152
+ def compare_measurements(
153
+ simulator_counts: Mapping[str, int],
154
+ hardware_counts: Mapping[str, int],
155
+ ) -> dict[str, dict[str, float]]:
156
+ """
157
+ Compare simulator and real hardware measurements.
158
+
159
+ Args:
160
+ simulator_counts: Counts obtained from simulation.
161
+ hardware_counts: Counts obtained from hardware.
162
+
163
+ Returns:
164
+ Dictionary containing simulator probabilities,
165
+ hardware probabilities and absolute differences.
166
+ """
167
+ simulator = calculate_probabilities(simulator_counts)
168
+ hardware = calculate_probabilities(hardware_counts)
169
+
170
+ if not simulator or not hardware:
171
+ raise ValueError(
172
+ "Both datasets must contain measurement shots."
173
+ )
174
+
175
+ states = sorted(set(simulator) | set(hardware))
176
+
177
+ return {
178
+ state: {
179
+ "simulator": simulator.get(state, 0.0),
180
+ "hardware": hardware.get(state, 0.0),
181
+ "difference": abs(
182
+ simulator.get(state, 0.0)
183
+ - hardware.get(state, 0.0)
184
+ ),
185
+ }
186
+ for state in states
187
+ }
188
+
189
+
190
+ def calculate_total_variation_distance(
191
+ counts_a: Mapping[str, int],
192
+ counts_b: Mapping[str, int],
193
+ ) -> float:
194
+ """
195
+ Calculate total variation distance between
196
+ two measurement distributions.
197
+
198
+ TVD = 1/2 * sum(|P(i) - Q(i)|)
199
+
200
+ Returns:
201
+ Value between 0 and 1.
202
+ """
203
+ comparison = compare_measurements(counts_a, counts_b)
204
+
205
+ return 0.5 * sum(
206
+ values["difference"]
207
+ for values in comparison.values()
208
+ )
209
+
210
+
211
+ def measurement_summary(
212
+ counts: Mapping[str, int],
213
+ ) -> dict:
214
+ """
215
+ Generate a statistical summary of measurement results.
216
+
217
+ Returns:
218
+ Dictionary with shot counts, probabilities,
219
+ percentages and most frequent state.
220
+ """
221
+ probabilities = calculate_probabilities(counts)
222
+
223
+ total_shots = sum(counts.values())
224
+
225
+ if total_shots == 0:
226
+ return {
227
+ "total_shots": 0,
228
+ "unique_states": 0,
229
+ "most_frequent_state": None,
230
+ "probabilities": {},
231
+ "percentages": {},
232
+ }
233
+
234
+ return {
235
+ "total_shots": total_shots,
236
+ "unique_states": sum(
237
+ count > 0 for count in counts.values()
238
+ ),
239
+ "most_frequent_state": max(
240
+ counts, key=counts.get
241
+ ),
242
+ "probabilities": probabilities,
243
+ "percentages": calculate_percentages(counts),
244
+ }
245
+
246
+
247
+ def _validate_counts(
248
+ counts: Mapping[str, int],
249
+ ) -> None:
250
+ """
251
+ Validate measurement count data.
252
+ """
253
+ for state, count in counts.items():
254
+
255
+ if not isinstance(state, str):
256
+ raise TypeError(
257
+ "Measurement states must be strings."
258
+ )
259
+
260
+ if not isinstance(count, (int, np.integer)):
261
+ raise TypeError(
262
+ "Measurement counts must be integers."
263
+ )
264
+
265
+ if count < 0:
266
+ raise ValueError(
267
+ "Measurement counts cannot be negative."
268
+ )
@@ -0,0 +1,371 @@
1
+
2
+ """
3
+ Visualization utilities for quantum computing.
4
+
5
+ Visualize quantum states, probabilities, measurement
6
+ counts, operators, and simulator/hardware comparisons.
7
+
8
+ Compatible with Jupyter and standard Python.
9
+ """
10
+
11
+ from collections.abc import Mapping, Sequence
12
+
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ from matplotlib.figure import Figure
16
+
17
+ from .measurements import (
18
+ calculate_probabilities,
19
+ calculate_state_probabilities,
20
+ compare_measurements,
21
+ calculate_total_variation_distance,
22
+ )
23
+
24
+
25
+ def _get_statevector(statevector) -> np.ndarray:
26
+ """Convert a statevector into a validated NumPy array."""
27
+ if hasattr(statevector, "data"):
28
+ statevector = statevector.data
29
+
30
+ array = np.asarray(statevector, dtype=complex)
31
+
32
+ if array.ndim != 1:
33
+ raise ValueError("Statevector must be one-dimensional.")
34
+
35
+ size = len(array)
36
+ if size == 0 or size & (size - 1):
37
+ raise ValueError("Statevector length must be a power of two.")
38
+
39
+ return array
40
+
41
+
42
+ def _basis_labels(size: int) -> list[str]:
43
+ """Create computational basis labels."""
44
+ if size <= 0 or size & (size - 1):
45
+ raise ValueError("Dimension must be a power of two.")
46
+
47
+ num_qubits = max(1, size.bit_length() - 1)
48
+
49
+ return [
50
+ format(i, f"0{num_qubits}b")
51
+ for i in range(size)
52
+ ]
53
+
54
+
55
+ def _finish(
56
+ fig: Figure,
57
+ show: bool,
58
+ ) -> Figure:
59
+ """Apply layout and optionally display a figure."""
60
+ fig.tight_layout()
61
+
62
+ if show:
63
+ plt.show()
64
+
65
+ return fig
66
+
67
+
68
+ def display_data(
69
+ data,
70
+ label: str = "Quantum Data",
71
+ show: bool = True,
72
+ ) -> Figure:
73
+ """
74
+ Display a quantum vector or matrix with numeric values.
75
+
76
+ Args:
77
+ data: One-dimensional vector or two-dimensional matrix.
78
+ label: Figure title.
79
+ show: Whether to display the figure.
80
+
81
+ Returns:
82
+ Matplotlib Figure.
83
+ """
84
+ array = np.asarray(data, dtype=complex)
85
+
86
+ if array.ndim not in (1, 2):
87
+ raise ValueError("Data must be a vector or matrix.")
88
+
89
+ if array.size == 0:
90
+ raise ValueError("Data cannot be empty.")
91
+
92
+ matrix = array.reshape(-1, 1) if array.ndim == 1 else array
93
+
94
+ rows, cols = matrix.shape
95
+
96
+ fig, ax = plt.subplots(
97
+ figsize=(
98
+ max(5, min(cols * 1.2, 16)),
99
+ max(2.5, min(rows * 0.55 + 1, 18)),
100
+ )
101
+ )
102
+
103
+ ax.axis("off")
104
+
105
+ formatted = [
106
+ [
107
+ f"{value.real:.3f}"
108
+ if np.isclose(value.imag, 0)
109
+ else f"{value.real:.3f}{value.imag:+.3f}j"
110
+ for value in row
111
+ ]
112
+ for row in matrix
113
+ ]
114
+
115
+ table = ax.table(
116
+ cellText=formatted,
117
+ loc="center",
118
+ cellLoc="center",
119
+ )
120
+
121
+ table.auto_set_font_size(False)
122
+ table.set_fontsize(10)
123
+ table.scale(1, 1.5)
124
+
125
+ ax.set_title(label, pad=18)
126
+
127
+ return _finish(fig, show)
128
+
129
+
130
+ def display_probabilities(
131
+ statevector: Sequence[complex],
132
+ label: str = "Theoretical Probabilities",
133
+ show: bool = True,
134
+ ) -> Figure:
135
+ """
136
+ Display theoretical probabilities using Born's rule.
137
+
138
+ Args:
139
+ statevector: Normalized quantum statevector.
140
+ label: Figure title.
141
+ show: Whether to display.
142
+
143
+ Returns:
144
+ Matplotlib Figure.
145
+ """
146
+ probabilities = calculate_state_probabilities(statevector)
147
+
148
+ states = list(probabilities)
149
+ values = [100 * p for p in probabilities.values()]
150
+
151
+ fig, ax = plt.subplots(figsize=(max(6, len(states) * 0.75), 4))
152
+
153
+ bars = ax.bar(states, values)
154
+
155
+ ax.set_title(label)
156
+ ax.set_xlabel("Computational basis state")
157
+ ax.set_ylabel("Probability (%)")
158
+ ax.set_ylim(0, max(5, max(values) * 1.2))
159
+
160
+ for bar, value in zip(bars, values):
161
+ ax.text(
162
+ bar.get_x() + bar.get_width() / 2,
163
+ bar.get_height(),
164
+ f"{value:.2f}%",
165
+ ha="center",
166
+ va="bottom",
167
+ fontsize=9,
168
+ )
169
+
170
+ return _finish(fig, show)
171
+
172
+
173
+ def display_counts(
174
+ counts: Mapping[str, int],
175
+ label: str = "Measurement Results",
176
+ show: bool = True,
177
+ ) -> Figure:
178
+ """
179
+ Display quantum measurement counts.
180
+
181
+ Works with counts from both simulators and real hardware.
182
+
183
+ Args:
184
+ counts: Dictionary mapping bitstrings to shot counts.
185
+ label: Name of the simulator or quantum computer.
186
+ show: Whether to display.
187
+
188
+ Returns:
189
+ Matplotlib Figure.
190
+ """
191
+ probabilities = calculate_probabilities(counts)
192
+
193
+ if not probabilities:
194
+ raise ValueError("No measurement shots available.")
195
+
196
+ states = list(probabilities)
197
+ values = [100 * p for p in probabilities.values()]
198
+ total_shots = sum(counts.values())
199
+
200
+ fig, ax = plt.subplots(figsize=(max(6, len(states) * 0.75), 4))
201
+
202
+ bars = ax.bar(states, values)
203
+
204
+ ax.set_title(f"{label}\n{total_shots} shots")
205
+ ax.set_xlabel("Measured state")
206
+ ax.set_ylabel("Frequency (%)")
207
+ ax.set_ylim(0, max(5, max(values) * 1.2))
208
+
209
+ for bar, state, value in zip(bars, states, values):
210
+ ax.text(
211
+ bar.get_x() + bar.get_width() / 2,
212
+ bar.get_height(),
213
+ f"{counts[state]} ({value:.1f}%)",
214
+ ha="center",
215
+ va="bottom",
216
+ fontsize=9,
217
+ )
218
+
219
+ return _finish(fig, show)
220
+
221
+
222
+ def compare_counts(
223
+ simulator_counts: Mapping[str, int],
224
+ hardware_counts: Mapping[str, int],
225
+ labels: tuple[str, str] = ("Simulator", "Hardware"),
226
+ show: bool = True,
227
+ ) -> Figure:
228
+ """
229
+ Compare simulator and hardware measurement distributions.
230
+
231
+ Args:
232
+ simulator_counts: Simulation results.
233
+ hardware_counts: Real hardware results.
234
+ labels: Names displayed in the legend.
235
+ show: Whether to display.
236
+
237
+ Returns:
238
+ Matplotlib Figure.
239
+ """
240
+ if len(labels) != 2:
241
+ raise ValueError("Exactly two labels are required.")
242
+
243
+ comparison = compare_measurements(
244
+ simulator_counts,
245
+ hardware_counts,
246
+ )
247
+
248
+ states = list(comparison)
249
+ simulator = [
250
+ comparison[s]["simulator"] * 100 for s in states
251
+ ]
252
+ hardware = [
253
+ comparison[s]["hardware"] * 100 for s in states
254
+ ]
255
+
256
+ x = np.arange(len(states))
257
+ width = 0.36
258
+
259
+ fig, ax = plt.subplots(
260
+ figsize=(max(7, len(states) * 0.9), 4.5)
261
+ )
262
+
263
+ ax.bar(
264
+ x - width / 2,
265
+ simulator,
266
+ width,
267
+ label=labels[0],
268
+ )
269
+ ax.bar(
270
+ x + width / 2,
271
+ hardware,
272
+ width,
273
+ label=labels[1],
274
+ )
275
+
276
+ tvd = calculate_total_variation_distance(
277
+ simulator_counts,
278
+ hardware_counts,
279
+ )
280
+
281
+ ax.set_title(
282
+ f"Quantum Measurement Comparison\n"
283
+ f"Total Variation Distance: {tvd:.4f}"
284
+ )
285
+
286
+ ax.set_xlabel("Measured state")
287
+ ax.set_ylabel("Frequency (%)")
288
+ ax.set_xticks(x, states)
289
+ ax.set_ylim(
290
+ 0,
291
+ max(5, max(simulator + hardware) * 1.2),
292
+ )
293
+ ax.legend()
294
+
295
+ return _finish(fig, show)
296
+
297
+
298
+ def display_operator_heatmap(
299
+ operator,
300
+ label: str = "Quantum Operator",
301
+ show: bool = True,
302
+ ) -> Figure:
303
+ """
304
+ Display the magnitude of a quantum operator matrix.
305
+
306
+ Args:
307
+ operator: Square matrix or compatible Qiskit Operator.
308
+ label: Figure title.
309
+ show: Whether to display.
310
+
311
+ Returns:
312
+ Matplotlib Figure.
313
+ """
314
+ if hasattr(operator, "data"):
315
+ operator = operator.data
316
+
317
+ matrix = np.asarray(operator, dtype=complex)
318
+
319
+ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
320
+ raise ValueError("Operator must be a square matrix.")
321
+
322
+ size = matrix.shape[0]
323
+ states = _basis_labels(size)
324
+
325
+ magnitude = np.abs(matrix)
326
+
327
+ fig, ax = plt.subplots(
328
+ figsize=(max(5, size * 0.9), max(4, size * 0.8))
329
+ )
330
+
331
+ image = ax.imshow(
332
+ magnitude,
333
+ cmap="Blues",
334
+ vmin=0,
335
+ vmax=max(1, float(magnitude.max())),
336
+ )
337
+
338
+ ax.set_xticks(range(size), [f"|{s}⟩" for s in states])
339
+ ax.set_yticks(range(size), [f"|{s}⟩" for s in states])
340
+ ax.set_xlabel("Input state")
341
+ ax.set_ylabel("Output state")
342
+ ax.set_title(label)
343
+
344
+ for row in range(size):
345
+ for col in range(size):
346
+ value = matrix[row, col]
347
+
348
+ if not np.isclose(value, 0):
349
+ text = (
350
+ f"{value.real:.2f}"
351
+ if np.isclose(value.imag, 0)
352
+ else f"{value.real:.2f}{value.imag:+.2f}j"
353
+ )
354
+
355
+ ax.text(
356
+ col,
357
+ row,
358
+ text,
359
+ ha="center",
360
+ va="center",
361
+ fontsize=9,
362
+ color=(
363
+ "white"
364
+ if magnitude[row, col] > 0.65
365
+ else "black"
366
+ ),
367
+ )
368
+
369
+ fig.colorbar(image, ax=ax, label="Magnitude")
370
+
371
+ return _finish(fig, show)