vqe-pennylane 0.2.2__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.
vqe/io_utils.py ADDED
@@ -0,0 +1,265 @@
1
+ """
2
+ vqe.io_utils
3
+ ------------
4
+ Utility functions for reproducible VQE runs:
5
+
6
+ - Run configuration construction & hashing
7
+ - JSON-safe serialization
8
+ - File/directory management for results & images
9
+
10
+ This module is the single source of truth for:
11
+ - Where new package results are stored
12
+ - How run configurations are represented and hashed
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json, os
18
+ import hashlib
19
+ from pathlib import Path
20
+ from typing import Any, Dict
21
+
22
+ # ================================================================
23
+ # BASE PATHS
24
+ # ================================================================
25
+
26
+ # Root of the repository/package (…/ <repo_root> / vqe / io_utils.py)
27
+ BASE_DIR: Path = Path(__file__).resolve().parent.parent
28
+
29
+ # New, package-scoped locations for results and images
30
+ RESULTS_DIR = BASE_DIR / "results" / "vqe"
31
+ IMG_DIR = BASE_DIR / "images" / "vqe"
32
+
33
+
34
+ # ================================================================
35
+ # INTERNAL HELPERS
36
+ # ================================================================
37
+
38
+ def _round_floats(x: Any, ndigits: int = 8) -> Any:
39
+ """
40
+ Recursively round floats / numpy scalars / arrays for stable hashing.
41
+
42
+ Parameters
43
+ ----------
44
+ x:
45
+ Arbitrary Python or numpy object.
46
+ ndigits:
47
+ Number of decimal places to round to.
48
+
49
+ Returns
50
+ -------
51
+ Any
52
+ Object of the same container structure with floats rounded.
53
+ """
54
+ # Plain Python float
55
+ if isinstance(x, float):
56
+ return round(x, ndigits)
57
+
58
+ # Numpy scalar-like: has .item() that is a float
59
+ try:
60
+ if hasattr(x, "item"):
61
+ scalar = x.item()
62
+ if isinstance(scalar, float):
63
+ return round(float(scalar), ndigits)
64
+ except Exception:
65
+ # Fall through if .item() misbehaves
66
+ pass
67
+
68
+ # Numpy arrays / array-like -> convert to list and recurse
69
+ if hasattr(x, "tolist"):
70
+ return _round_floats(x.tolist(), ndigits)
71
+
72
+ # Python containers
73
+ if isinstance(x, (list, tuple)):
74
+ return type(x)(_round_floats(v, ndigits) for v in x)
75
+
76
+ # Everything else left untouched
77
+ return x
78
+
79
+
80
+ def _to_serializable(obj: Any) -> Any:
81
+ """
82
+ Convert tensors / numpy arrays / complex containers into JSON-safe types.
83
+
84
+ Rules of thumb:
85
+ - numpy / tensor-like with .tolist() → list / nested lists
86
+ - numpy scalar with .item() → float (when possible)
87
+ - dict / list / tuple → recurse
88
+ - everything else returned unchanged
89
+ """
90
+ # Numpy / tensor scalar
91
+ if hasattr(obj, "item"):
92
+ try:
93
+ return obj.item()
94
+ except Exception:
95
+ pass
96
+
97
+ # Numpy arrays and friends
98
+ if hasattr(obj, "tolist"):
99
+ return obj.tolist()
100
+
101
+ # Dicts
102
+ if isinstance(obj, dict):
103
+ return {k: _to_serializable(v) for k, v in obj.items()}
104
+
105
+ # Lists / tuples
106
+ if isinstance(obj, (list, tuple)):
107
+ return [_to_serializable(v) for v in obj]
108
+
109
+ # Primitive or unknown: hope json can handle it
110
+ return obj
111
+
112
+
113
+ # ================================================================
114
+ # RUN CONFIGURATION & HASHING
115
+ # ================================================================
116
+
117
+ def make_run_config_dict(
118
+ symbols,
119
+ coordinates,
120
+ basis: str,
121
+ ansatz_desc: str,
122
+ optimizer_name: str,
123
+ stepsize: float,
124
+ max_iterations: int,
125
+ seed: int,
126
+ mapping: str,
127
+ noisy: bool = False,
128
+ depolarizing_prob: float = 0.0,
129
+ amplitude_damping_prob: float = 0.0,
130
+ molecule_label: str | None = None,
131
+ ) -> Dict[str, Any]:
132
+ """
133
+ Construct a canonical dictionary describing a VQE/SSVQE run configuration.
134
+
135
+ This dictionary is used to generate a stable hash signature
136
+ (see :func:`run_signature`) so that identical configurations
137
+ always map to the same cache key / filename.
138
+ """
139
+ cfg: Dict[str, Any] = {
140
+ "symbols": list(symbols),
141
+ "geometry": _round_floats(coordinates, 8),
142
+ "basis": basis,
143
+ "ansatz": ansatz_desc,
144
+ "optimizer": {
145
+ "name": optimizer_name,
146
+ "stepsize": float(stepsize),
147
+ "iterations_planned": int(max_iterations),
148
+ },
149
+ "optimizer_name": optimizer_name, # convenient flat copy
150
+ "seed": int(seed),
151
+ "noisy": bool(noisy),
152
+ "depolarizing_prob": float(depolarizing_prob),
153
+ "amplitude_damping_prob": float(amplitude_damping_prob),
154
+ "mapping": mapping.lower(),
155
+ }
156
+
157
+ if molecule_label is not None:
158
+ cfg["molecule"] = str(molecule_label)
159
+
160
+ return cfg
161
+
162
+
163
+ def run_signature(cfg: Dict[str, Any]) -> str:
164
+ """
165
+ Generate a short, stable hash identifier from a run configuration.
166
+
167
+ The configuration is JSON-serialized with sorted keys and compact
168
+ separators, then hashed with SHA-256. The first 12 hex characters
169
+ are used as the signature.
170
+
171
+ Parameters
172
+ ----------
173
+ cfg:
174
+ Configuration dictionary as returned by :func:`make_run_config_dict`.
175
+
176
+ Returns
177
+ -------
178
+ str
179
+ 12-character hexadecimal signature.
180
+ """
181
+ payload = json.dumps(cfg, sort_keys=True, separators=(",", ":"))
182
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
183
+
184
+
185
+ # ================================================================
186
+ # FILESYSTEM UTILITIES
187
+ # ================================================================
188
+
189
+ def ensure_dirs() -> None:
190
+ """
191
+ Ensure that the standard result and image directories exist.
192
+
193
+ - RESULTS_DIR: where JSON run records are written (package_results/)
194
+ - IMG_DIR: where plots and figures are saved (vqe/images/)
195
+ """
196
+ RESULTS_DIR.mkdir(parents=True, exist_ok=True)
197
+ IMG_DIR.mkdir(parents=True, exist_ok=True)
198
+
199
+
200
+ def _result_path_from_prefix(prefix: str) -> Path:
201
+ """
202
+ Build the full JSON path from a filename prefix (without extension).
203
+
204
+ Parameters
205
+ ----------
206
+ prefix:
207
+ Filename prefix, e.g. "H2_Adam_s0__abc123def456".
208
+
209
+ Returns
210
+ -------
211
+ Path
212
+ Path to the JSON file within RESULTS_DIR.
213
+ """
214
+ return RESULTS_DIR / f"{prefix}.json"
215
+
216
+
217
+ def save_run_record(prefix: str, record: Dict[str, Any]) -> str:
218
+ """
219
+ Save a run record (config + results) as JSON in RESULTS_DIR.
220
+
221
+ The final path is:
222
+ RESULTS_DIR / f"{prefix}.json"
223
+
224
+ Parameters
225
+ ----------
226
+ prefix:
227
+ Unique filename prefix, typically including molecule, optimizer,
228
+ seed, and the run signature.
229
+ record:
230
+ Dictionary containing configuration, results, and any metadata.
231
+
232
+ Returns
233
+ -------
234
+ str
235
+ String path to the saved JSON file.
236
+ """
237
+ ensure_dirs()
238
+ path = _result_path_from_prefix(prefix)
239
+ serializable_record = _to_serializable(record)
240
+
241
+ with path.open("w") as f:
242
+ json.dump(serializable_record, f, indent=2)
243
+
244
+ return str(path)
245
+
246
+
247
+ def make_filename_prefix(cfg: dict, *, noisy: bool, seed: int, hash_str: str, ssvqe: bool = False):
248
+ """Return unified Option-C filename prefix."""
249
+ # Molecule lives in config only indirectly; infer from symbols
250
+ # OR require callers to inject molecule into cfg beforehand.
251
+ mol = cfg.get("molecule", "MOL") # We will fix cfg in core/ssvqe.
252
+
253
+ # Ansatz string taken directly
254
+ ans = cfg.get("ansatz", "ANSATZ")
255
+
256
+ # Optimizer name
257
+ if "optimizer" in cfg and "name" in cfg["optimizer"]:
258
+ opt = cfg["optimizer"]["name"]
259
+ else:
260
+ opt = "OPT"
261
+
262
+ noise_tag = "noisy" if noisy else "noiseless"
263
+ algo_tag = "SSVQE" if ssvqe else "VQE"
264
+
265
+ return f"{mol}__{ans}__{opt}__{algo_tag}__{noise_tag}__s{seed}__{hash_str}"
vqe/optimizer.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ vqe.optimizer
3
+ -------------
4
+ Lightweight wrapper over PennyLane optimizers with a unified interface.
5
+
6
+ Provides:
7
+ - get_optimizer(name, stepsize)
8
+ """
9
+
10
+ from __future__ import annotations
11
+ import pennylane as qml
12
+
13
+
14
+ # ================================================================
15
+ # AVAILABLE OPTIMIZERS
16
+ # ================================================================
17
+ _OPTIMIZERS = {
18
+ "Adam": qml.AdamOptimizer,
19
+ "adam": qml.AdamOptimizer, # alias
20
+
21
+ "GradientDescent": qml.GradientDescentOptimizer,
22
+ "gd": qml.GradientDescentOptimizer, # alias
23
+
24
+ "Momentum": qml.MomentumOptimizer,
25
+ "Nesterov": qml.NesterovMomentumOptimizer,
26
+
27
+ "RMSProp": qml.RMSPropOptimizer,
28
+ "Adagrad": qml.AdagradOptimizer,
29
+
30
+ # ---- NEW ADDITIONS (SPSA SUPPORT) ----
31
+ "SPSA": qml.SPSAOptimizer,
32
+ "spsa": qml.SPSAOptimizer, # alias
33
+ }
34
+
35
+
36
+ # ================================================================
37
+ # MAIN FACTORY
38
+ # ================================================================
39
+ def get_optimizer(name: str = "Adam", stepsize: float = 0.2):
40
+ """
41
+ Return a PennyLane optimizer instance by name.
42
+
43
+ Args:
44
+ name: Optimizer identifier (case-insensitive).
45
+ stepsize: Learning rate.
46
+
47
+ Returns:
48
+ An instantiated optimizer.
49
+ """
50
+ key = name.lower()
51
+ for k, cls in _OPTIMIZERS.items():
52
+ if k.lower() == key:
53
+ return cls(stepsize)
54
+
55
+ raise ValueError(
56
+ f"Unknown optimizer '{name}'. "
57
+ f"Available: {', '.join(_OPTIMIZERS.keys())}"
58
+ )
vqe/ssvqe.py ADDED
@@ -0,0 +1,271 @@
1
+ """
2
+ vqe.ssvqe
3
+ ---------
4
+ Subspace-Search Variational Quantum Eigensolver (SSVQE).
5
+
6
+ Features:
7
+ - Ground and excited states via a shared variational ansatz
8
+ - Orthogonality enforced with |⟨ψ_i|ψ_j⟩|² penalties
9
+ - Any ansatz and optimizer defined in the vqe package
10
+ - Optional depolarizing / amplitude-damping noise
11
+ - Caching and reproducibility via vqe.io_utils
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import itertools
19
+ import pennylane as qml
20
+ from pennylane import numpy as np
21
+
22
+ from .hamiltonian import build_hamiltonian
23
+ from .engine import (
24
+ make_device,
25
+ build_ansatz,
26
+ build_optimizer,
27
+ make_energy_qnode,
28
+ make_overlap00_fn,
29
+ )
30
+ from .io_utils import (
31
+ ensure_dirs,
32
+ make_run_config_dict,
33
+ run_signature,
34
+ save_run_record,
35
+ make_filename_prefix,
36
+ RESULTS_DIR,
37
+ )
38
+ from .visualize import plot_ssvqe_convergence_multi
39
+
40
+
41
+ # ================================================================
42
+ # MAIN ENTRYPOINT
43
+ # ================================================================
44
+ def run_ssvqe(
45
+ molecule: str = "H3+",
46
+ *,
47
+ num_states: int = 2,
48
+ penalty_weight: float = 10.0,
49
+ ansatz_name: str = "UCCSD",
50
+ optimizer_name: str = "Adam",
51
+ steps: int = 100,
52
+ stepsize: float = 0.4,
53
+ seed: int = 0,
54
+ noisy: bool = False,
55
+ depolarizing_prob: float = 0.0,
56
+ amplitude_damping_prob: float = 0.0,
57
+ symbols=None,
58
+ coordinates=None,
59
+ basis: str = "sto-3g",
60
+ plot: bool = True,
61
+ force: bool = False,
62
+ ):
63
+ """
64
+ Run a Subspace-Search VQE (SSVQE) optimization to obtain ground and
65
+ excited states of a molecular Hamiltonian.
66
+
67
+ Args:
68
+ molecule: Molecule label (e.g. "H2", "LiH", "H3+").
69
+ num_states: Number of eigenstates to target (>= 2).
70
+ penalty_weight: Weight on the orthogonality penalty term.
71
+ ansatz_name: Name of the ansatz from `vqe.ansatz`.
72
+ optimizer_name: Name of optimizer from `vqe.optimizer`.
73
+ steps: Number of optimization iterations.
74
+ stepsize: Optimizer step size.
75
+ seed: Random seed for reproducibility.
76
+ noisy: If True, use a mixed-state simulator and insert noise.
77
+ depolarizing_prob: Depolarizing noise probability per wire.
78
+ amplitude_damping_prob: Amplitude damping probability per wire.
79
+ symbols, coordinates, basis: Optional explicit molecular data.
80
+ plot: Whether to plot E0 / E1 convergence (if num_states >= 2).
81
+ force: If True, ignore cached results and recompute.
82
+
83
+ Returns:
84
+ dict with keys:
85
+ - "energies_per_state": list[list[float]]
86
+ - "final_params": list[list[float]]
87
+ - "config": dict
88
+ """
89
+ assert num_states >= 2, "SSVQE requires at least two target states."
90
+
91
+ np.random.seed(seed)
92
+ ensure_dirs()
93
+
94
+ # ============================================================
95
+ # 1. Build Hamiltonian and molecular data
96
+ # ============================================================
97
+ if symbols is None or coordinates is None:
98
+ H, num_wires, symbols, coordinates, basis = build_hamiltonian(molecule)
99
+ else:
100
+ # Default charge heuristic (matches your prior H3+ usage)
101
+ charge = +1 if molecule.upper() == "H3+" else 0
102
+ H, num_wires = qml.qchem.molecular_hamiltonian(
103
+ symbols, coordinates, charge=charge, basis=basis, unit="angstrom"
104
+ )
105
+
106
+ # ============================================================
107
+ # 2. Ansatz and initial parameters (shared structure)
108
+ # ============================================================
109
+ ansatz_fn, init_params = build_ansatz(
110
+ ansatz_name,
111
+ num_wires,
112
+ seed=seed,
113
+ symbols=symbols,
114
+ coordinates=coordinates,
115
+ basis=basis,
116
+ )
117
+ # One parameter set per state, all with same shape
118
+ param_sets = [np.array(init_params, requires_grad=True) for _ in range(num_states)]
119
+
120
+ # ============================================================
121
+ # 3. Device and QNodes
122
+ # ============================================================
123
+ dev = make_device(num_wires, noisy=noisy)
124
+
125
+ energy_qnode = make_energy_qnode(
126
+ H,
127
+ dev,
128
+ ansatz_fn,
129
+ num_wires,
130
+ noisy=noisy,
131
+ depolarizing_prob=depolarizing_prob,
132
+ amplitude_damping_prob=amplitude_damping_prob,
133
+ symbols=symbols,
134
+ coordinates=coordinates,
135
+ basis=basis,
136
+ )
137
+
138
+ overlap00 = make_overlap00_fn(
139
+ dev,
140
+ ansatz_fn,
141
+ num_wires,
142
+ noisy=noisy,
143
+ depolarizing_prob=depolarizing_prob,
144
+ amplitude_damping_prob=amplitude_damping_prob,
145
+ symbols=symbols,
146
+ coordinates=coordinates,
147
+ basis=basis,
148
+ )
149
+
150
+ # ============================================================
151
+ # 4. Config + caching
152
+ # ============================================================
153
+ cfg = make_run_config_dict(
154
+ symbols=symbols,
155
+ coordinates=coordinates,
156
+ basis=basis,
157
+ ansatz_desc=f"SSVQE({ansatz_name})_{num_states}states",
158
+ optimizer_name=optimizer_name,
159
+ stepsize=stepsize,
160
+ max_iterations=steps,
161
+ seed=seed,
162
+ mapping="jordan_wigner",
163
+ noisy=noisy,
164
+ depolarizing_prob=depolarizing_prob,
165
+ amplitude_damping_prob=amplitude_damping_prob,
166
+ molecule_label=molecule, # <<< add this
167
+ )
168
+ cfg["penalty_weight"] = float(penalty_weight)
169
+ cfg["num_states"] = int(num_states)
170
+
171
+ sig = run_signature(cfg)
172
+ safe_molecule = molecule.replace("+", "plus")
173
+ prefix = make_filename_prefix(
174
+ cfg,
175
+ noisy=noisy,
176
+ seed=seed,
177
+ hash_str=sig,
178
+ ssvqe=True,
179
+ )
180
+ result_path = RESULTS_DIR / f"{prefix}.json"
181
+
182
+ if not force and result_path.exists():
183
+ print(f"📂 Using cached SSVQE result: {result_path}")
184
+ with open(result_path, "r") as f:
185
+ record = json.load(f)
186
+ return record["result"]
187
+
188
+ # ============================================================
189
+ # 5. Cost function: total energy + orthogonality penalties
190
+ # ============================================================
191
+ opt = build_optimizer(optimizer_name, stepsize=stepsize)
192
+
193
+ def _unpack_flat(flat, templates):
194
+ """Unpack a flat parameter vector into a list of arrays matching templates."""
195
+ arrays = []
196
+ idx = 0
197
+ for tmpl in templates:
198
+ size = int(np.prod(tmpl.shape))
199
+ vec = flat[idx:idx + size]
200
+ arrays.append(np.reshape(vec, tmpl.shape))
201
+ idx += size
202
+ return arrays
203
+
204
+ def cost(flat_params):
205
+ # Split into per-state parameter arrays
206
+ unpacked = _unpack_flat(flat_params, param_sets)
207
+
208
+ # Sum of individual energies
209
+ total = sum(energy_qnode(p) for p in unpacked)
210
+
211
+ # Add orthogonality penalties between all distinct pairs
212
+ for i, j in itertools.combinations(range(len(unpacked)), 2):
213
+ total = total + penalty_weight * overlap00(unpacked[i], unpacked[j])
214
+
215
+ return total
216
+
217
+ # Flatten initial parameters for joint optimization
218
+ flat = np.concatenate([p.ravel() for p in param_sets])
219
+ flat = np.array(flat, requires_grad=True)
220
+
221
+ energies_per_state = [[] for _ in range(num_states)]
222
+
223
+ # ============================================================
224
+ # 6. Optimization loop
225
+ # ============================================================
226
+ for step in range(steps):
227
+ try:
228
+ flat, _ = opt.step_and_cost(cost, flat)
229
+ except AttributeError:
230
+ flat = opt.step(cost, flat)
231
+
232
+ # Unpack back into per-state parameter arrays
233
+ unpacked = _unpack_flat(flat, param_sets)
234
+
235
+ # Record energies for each state
236
+ for k in range(num_states):
237
+ energies_per_state[k].append(float(energy_qnode(unpacked[k])))
238
+
239
+ # Update param_sets for the next iteration (keep grad enabled)
240
+ param_sets = [np.array(u, requires_grad=True) for u in unpacked]
241
+
242
+ # ============================================================
243
+ # 7. Package and save results
244
+ # ============================================================
245
+ result = {
246
+ "energies_per_state": energies_per_state,
247
+ "final_params": [u.tolist() for u in param_sets],
248
+ "config": cfg,
249
+ }
250
+
251
+ save_run_record(prefix, {"config": cfg, "result": result})
252
+ print(f"💾 Saved SSVQE run to {result_path}")
253
+
254
+ # ============================================================
255
+ # 8. Optional plotting (E0 / E1 only, if available)
256
+ # ============================================================
257
+ if plot and num_states >= 2:
258
+ try:
259
+ E0 = energies_per_state[0]
260
+ E1 = energies_per_state[1]
261
+ plot_ssvqe_convergence_multi(
262
+ molecule,
263
+ E0_list=E0,
264
+ E1_list=E1,
265
+ optimizer_name=optimizer_name,
266
+ ansatz=ansatz_name,
267
+ )
268
+ except Exception as exc: # plotting is non-fatal
269
+ print(f"⚠️ SSVQE plotting failed (non-fatal): {exc}")
270
+
271
+ return result