Coverage for qml_essentials/model.py: 91%

208 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-23 11:23 +0000

1from typing import Dict, Optional, Tuple, Callable, Union, List 

2import pennylane as qml 

3import pennylane.numpy as np 

4import hashlib 

5import os 

6import warnings 

7from autograd.numpy import numpy_boxes 

8 

9from qml_essentials.ansaetze import Gates, Ansaetze, Circuit 

10 

11import logging 

12 

13log = logging.getLogger(__name__) 

14 

15 

16class Model: 

17 """ 

18 A quantum circuit model. 

19 """ 

20 

21 def __init__( 

22 self, 

23 n_qubits: int, 

24 n_layers: int, 

25 circuit_type: Union[str, Circuit], 

26 data_reupload: bool = True, 

27 encoding: Union[str, Callable, List[str], List[Callable]] = Gates.RX, 

28 initialization: str = "random", 

29 initialization_domain: List[float] = [0, 2 * np.pi], 

30 output_qubit: Union[List[int], int] = -1, 

31 shots: Optional[int] = None, 

32 random_seed: int = 1000, 

33 ) -> None: 

34 """ 

35 Initialize the quantum circuit model. 

36 Parameters will have the shape [impl_n_layers, parameters_per_layer] 

37 where impl_n_layers is the number of layers provided and added by one 

38 depending if data_reupload is True and parameters_per_layer is given by 

39 the chosen ansatz. 

40 

41 The model is initialized with the following parameters as defaults: 

42 - noise_params: None 

43 - execution_type: "expval" 

44 - shots: None 

45 

46 Args: 

47 n_qubits (int): The number of qubits in the circuit. 

48 n_layers (int): The number of layers in the circuit. 

49 circuit_type (str, Circuit): The type of quantum circuit to use. 

50 If None, defaults to "no_ansatz". 

51 data_reupload (bool, optional): Whether to reupload data to the 

52 quantum device on each measurement. Defaults to True. 

53 encoding (Union[str, Callable, List[str], List[Callable]], optional): 

54 The unitary to use for encoding the input data. Can be a string 

55 (e.g. "RX") or a callable (e.g. qml.RX). Defaults to qml.RX. 

56 If input is multidimensional it is assumed to be a list of 

57 unitaries or a list of strings. 

58 initialization (str, optional): The strategy to initialize the parameters. 

59 Can be "random", "zeros", "zero-controlled", "pi", or "pi-controlled". 

60 Defaults to "random". 

61 output_qubit (List[int], int, optional): The index of the output 

62 qubit (or qubits). When set to -1 all qubits are measured, or a 

63 global measurement is conducted, depending on the execution 

64 type. 

65 shots (Optional[int], optional): The number of shots to use for 

66 the quantum device. Defaults to None. 

67 random_seed (int, optional): seed for the random number generator 

68 in initialization is "random", Defaults to 1000. 

69 

70 Returns: 

71 None 

72 """ 

73 # Initialize default parameters needed for circuit evaluation 

74 self.noise_params: Optional[Dict[str, float]] = None 

75 self.execution_type: Optional[str] = "expval" 

76 self.shots = shots 

77 self.output_qubit: Union[List[int], int] = output_qubit 

78 

79 # Copy the parameters 

80 self.n_qubits: int = n_qubits 

81 self.n_layers: int = n_layers 

82 self.data_reupload: bool = data_reupload 

83 

84 lightning_threshold = 12 

85 

86 # Initialize ansatz 

87 # only weak check for str. We trust the user to provide sth useful 

88 if isinstance(circuit_type, str): 

89 self.pqc: Callable[[Optional[np.ndarray], int], int] = getattr( 

90 Ansaetze, circuit_type or "No_Ansatz" 

91 )() 

92 else: 

93 self.pqc = circuit_type() 

94 

95 # Initialize encoding 

96 # first check if we have a str, list or callable 

97 if isinstance(encoding, str): 

98 # if str, use the pennylane fct 

99 self._enc = getattr(Gates, f"{encoding}") 

100 elif isinstance(encoding, list): 

101 # if list, check if str or callable 

102 if isinstance(encoding[0], str): 

103 self._enc = [getattr(Gates, f"{enc}") for enc in encoding] 

104 else: 

105 self._enc = encoding 

106 

107 if len(self._enc) == 1: 

108 self._enc = self._enc[0] 

109 else: 

110 # default to callable 

111 self._enc = encoding 

112 

113 log.info(f"Using {circuit_type} circuit.") 

114 

115 if data_reupload: 

116 impl_n_layers: int = n_layers + 1 # we need L+1 according to Schuld et al. 

117 self.degree = n_layers * n_qubits 

118 else: 

119 impl_n_layers: int = n_layers 

120 self.degree = 1 

121 

122 log.info(f"Number of implicit layers set to {impl_n_layers}.") 

123 # calculate the shape of the parameter vector here, we will re-use this in init. 

124 self._params_shape: Tuple[int, int] = ( 

125 impl_n_layers, 

126 self.pqc.n_params_per_layer(self.n_qubits), 

127 ) 

128 # this will also be re-used in the init method, 

129 # however, only if nothing is provided 

130 self._inialization_strategy = initialization 

131 self._initialization_domain = initialization_domain 

132 

133 # ..here! where we only require a rng 

134 self.initialize_params(np.random.default_rng(random_seed)) 

135 

136 # Initialize two circuits, one with the default device and 

137 # one with the mixed device 

138 # which allows us to later route depending on the state_vector flag 

139 self.circuit: qml.QNode = qml.QNode( 

140 self._circuit, 

141 qml.device( 

142 ( 

143 "default.qubit" 

144 if self.n_qubits < lightning_threshold 

145 else "lightning.qubit" 

146 ), 

147 shots=self.shots, 

148 wires=self.n_qubits, 

149 ), 

150 interface="autograd" if self.shots is not None else "auto", 

151 diff_method="parameter-shift" if self.shots is not None else "best", 

152 ) 

153 self.circuit_mixed: qml.QNode = qml.QNode( 

154 self._circuit, 

155 qml.device("default.mixed", shots=self.shots, wires=self.n_qubits), 

156 ) 

157 

158 @property 

159 def noise_params(self) -> Optional[Dict[str, float]]: 

160 """ 

161 Gets the noise parameters of the model. 

162 

163 Returns: 

164 Optional[Dict[str, float]]: A dictionary of 

165 noise parameters or None if not set. 

166 """ 

167 return self._noise_params 

168 

169 @noise_params.setter 

170 def noise_params(self, value: Optional[Dict[str, float]]) -> None: 

171 """ 

172 Sets the noise parameters of the model. 

173 

174 Args: 

175 value (Optional[Dict[str, float]]): A dictionary of noise parameters. 

176 If all values are 0.0, the noise parameters are set to None. 

177 

178 Returns: 

179 None 

180 """ 

181 if value is not None and all(np == 0.0 for np in value.values()): 

182 value = None 

183 self._noise_params = value 

184 

185 @property 

186 def execution_type(self) -> str: 

187 """ 

188 Gets the execution type of the model. 

189 

190 Returns: 

191 str: The execution type, one of 'density', 'expval', or 'probs'. 

192 """ 

193 return self._execution_type 

194 

195 @execution_type.setter 

196 def execution_type(self, value: str) -> None: 

197 if value not in ["density", "expval", "probs"]: 

198 raise ValueError(f"Invalid execution type: {value}.") 

199 

200 if value == "density" and self.output_qubit != -1: 

201 warnings.warn( 

202 f"{value} measurement does ignore output_qubit, which is " 

203 f"{self.output_qubit}.", 

204 UserWarning, 

205 ) 

206 

207 if value == "probs" and self.shots is None: 

208 warnings.warn( 

209 "Setting execution_type to probs without specifying shots.", UserWarning 

210 ) 

211 

212 if value == "density" and self.shots is not None: 

213 warnings.warn( 

214 "Setting execution_type to density with specified shots.", UserWarning 

215 ) 

216 

217 self._execution_type = value 

218 

219 @property 

220 def shots(self) -> Optional[int]: 

221 """ 

222 Gets the number of shots to use for the quantum device. 

223 

224 Returns: 

225 Optional[int]: The number of shots. 

226 """ 

227 return self._shots 

228 

229 @shots.setter 

230 def shots(self, value: Optional[int]) -> None: 

231 """ 

232 Sets the number of shots to use for the quantum device. 

233 

234 Args: 

235 value (Optional[int]): The number of shots. 

236 If an integer less than or equal to 0 is provided, it is set to None. 

237 

238 Returns: 

239 None 

240 """ 

241 if type(value) is int and value <= 0: 

242 value = None 

243 self._shots = value 

244 

245 def initialize_params( 

246 self, 

247 rng, 

248 repeat: int = None, 

249 initialization: str = None, 

250 initialization_domain: List[float] = None, 

251 ) -> None: 

252 """ 

253 Initializes the parameters of the model. 

254 

255 Args: 

256 rng: A random number generator to use for initialization. 

257 repeat: The number of times to repeat the parameters. 

258 If None, the number of layers is used. 

259 initialization: The strategy to use for parameter initialization. 

260 If None, the strategy specified in the constructor is used. 

261 initialization_domain: The domain to use for parameter initialization. 

262 If None, the domain specified in the constructor is used. 

263 

264 Returns: 

265 None 

266 """ 

267 params_shape = ( 

268 self._params_shape if repeat is None else [*self._params_shape, repeat] 

269 ) 

270 # use existing strategy if not specified 

271 initialization = initialization or self._inialization_strategy 

272 initialization_domain = initialization_domain or self._initialization_domain 

273 

274 def set_control_params(params: np.ndarray, value: float) -> np.ndarray: 

275 indices = self.pqc.get_control_indices(self.n_qubits) 

276 if indices is None: 

277 warnings.warn( 

278 f"Specified {initialization} but circuit\ 

279 does not contain controlled rotation gates.\ 

280 Parameters are intialized randomly.", 

281 UserWarning, 

282 ) 

283 else: 

284 params[:, indices[0] : indices[1] : indices[2]] = ( 

285 np.ones_like(params[:, indices[0] : indices[1] : indices[2]]) 

286 * value 

287 ) 

288 return params 

289 

290 if initialization == "random": 

291 self.params: np.ndarray = rng.uniform( 

292 *initialization_domain, params_shape, requires_grad=True 

293 ) 

294 elif initialization == "zeros": 

295 self.params: np.ndarray = np.zeros(params_shape, requires_grad=True) 

296 elif initialization == "pi": 

297 self.params: np.ndarray = np.ones(params_shape, requires_grad=True) * np.pi 

298 elif initialization == "zero-controlled": 

299 self.params: np.ndarray = rng.uniform( 

300 *initialization_domain, params_shape, requires_grad=True 

301 ) 

302 self.params = set_control_params(self.params, 0) 

303 elif initialization == "pi-controlled": 

304 self.params: np.ndarray = rng.uniform( 

305 *initialization_domain, params_shape, requires_grad=True 

306 ) 

307 self.params = set_control_params(self.params, np.pi) 

308 else: 

309 raise Exception("Invalid initialization method") 

310 

311 log.info( 

312 f"Initialized parameters with shape {self.params.shape}\ 

313 using strategy {initialization}." 

314 ) 

315 

316 def _iec( 

317 self, 

318 inputs: np.ndarray, 

319 data_reupload: bool, 

320 enc: Union[Callable, List[Callable]], 

321 noise_params: Optional[np.ndarray] = None, 

322 ) -> None: 

323 """ 

324 Creates an AngleEncoding using RX gates 

325 

326 Args: 

327 inputs (np.ndarray): length of vector must be 1, shape (1,) 

328 data_reupload (bool, optional): Whether to reupload the data 

329 for the IEC or not, default is True. 

330 

331 Returns: 

332 None 

333 """ 

334 # check for zero, because due to input validation, input cannot be none 

335 if not inputs.any(): 

336 return 

337 

338 if data_reupload: 

339 if inputs.shape[1] == 1: 

340 for q in range(self.n_qubits): 

341 enc(inputs[:, 0], wires=q, noise_params=noise_params) 

342 else: 

343 for q in range(self.n_qubits): 

344 for idx in range(inputs.shape[1]): 

345 enc[idx](inputs[:, idx], wires=q, noise_params=noise_params) 

346 else: 

347 if inputs.shape[1] == 1: 

348 enc(inputs[:, 0], wires=0, noise_params=noise_params) 

349 else: 

350 for idx in range(inputs.shape[1]): 

351 enc[idx](inputs[:, idx], wires=0, noise_params=noise_params) 

352 

353 def _circuit( 

354 self, 

355 params: np.ndarray, 

356 inputs: np.ndarray, 

357 ) -> Union[float, np.ndarray]: 

358 """ 

359 Creates a circuit with noise. 

360 

361 Args: 

362 params (np.ndarray): weight vector of shape 

363 [n_layers, n_qubits*n_params_per_layer] 

364 inputs (np.ndarray): input vector of size 1 

365 Returns: 

366 Union[float, np.ndarray]: Expectation value of PauliZ(0) 

367 of the circuit if state_vector is False and exp_val is True, 

368 otherwise the density matrix of all qubits. 

369 """ 

370 

371 for layer in range(0, self.n_layers): 

372 self.pqc(params[layer], self.n_qubits, noise_params=self.noise_params) 

373 

374 if self.data_reupload or layer == 0: 

375 self._iec( 

376 inputs, 

377 data_reupload=self.data_reupload, 

378 enc=self._enc, 

379 noise_params=self.noise_params, 

380 ) 

381 

382 qml.Barrier(wires=list(range(self.n_qubits)), only_visual=True) 

383 

384 if self.data_reupload: 

385 self.pqc(params[-1], self.n_qubits, noise_params=self.noise_params) 

386 

387 if self.noise_params is not None: 

388 for q in range(self.n_qubits): 

389 qml.AmplitudeDamping( 

390 self.noise_params.get("AmplitudeDamping", 0.0), wires=q 

391 ) 

392 qml.PhaseDamping(self.noise_params.get("PhaseDamping", 0.0), wires=q) 

393 

394 # run mixed simualtion and get density matrix 

395 if self.execution_type == "density": 

396 return qml.density_matrix(wires=list(range(self.n_qubits))) 

397 # run default simulation and get expectation value 

398 elif self.execution_type == "expval": 

399 # global measurement (tensored Pauli Z, i.e. parity) 

400 if self.output_qubit == -1: 

401 return [qml.expval(qml.PauliZ(q)) for q in range(self.n_qubits)] 

402 # local measurement(s) 

403 elif isinstance(self.output_qubit, int): 

404 return qml.expval(qml.PauliZ(self.output_qubit)) 

405 # n-local measurenment 

406 elif isinstance(self.output_qubit, list): 

407 obs = qml.simplify( 

408 qml.Hamiltonian( 

409 [1.0] * self.n_qubits, 

410 [qml.PauliZ(q) for q in self.output_qubit], 

411 ) 

412 ) 

413 return qml.expval(obs) 

414 else: 

415 raise ValueError( 

416 f"Invalid parameter 'output_qubit': {self.output_qubit}.\ 

417 Must be int, list or -1." 

418 ) 

419 # run default simulation and get probs 

420 elif self.execution_type == "probs": 

421 if self.output_qubit == -1: 

422 return qml.probs(wires=list(range(self.n_qubits))) 

423 else: 

424 return qml.probs(wires=self.output_qubit) 

425 else: 

426 raise ValueError(f"Invalid execution_type: {self.execution_type}.") 

427 

428 def _draw(self, inputs=None, figure=False) -> None: 

429 if isinstance(self.circuit, qml.qnn.torch.TorchLayer): 

430 # TODO: throws strange argument error if not catched 

431 return "" 

432 

433 inputs = self._inputs_validation(inputs) 

434 

435 if figure: 

436 result = qml.draw_mpl(self.circuit)(params=self.params, inputs=inputs) 

437 else: 

438 result = qml.draw(self.circuit)(params=self.params, inputs=inputs) 

439 return result 

440 

441 def draw(self, inputs=None, figure=False) -> None: 

442 

443 return self._draw(inputs, figure) 

444 

445 def __repr__(self) -> str: 

446 return self._draw(figure=False) 

447 

448 def __str__(self) -> str: 

449 return self._draw(figure=False) 

450 

451 def __call__( 

452 self, 

453 params: Optional[np.ndarray] = None, 

454 inputs: Optional[np.ndarray] = None, 

455 noise_params: Optional[Dict[str, float]] = None, 

456 cache: Optional[bool] = False, 

457 execution_type: Optional[str] = None, 

458 force_mean: Optional[bool] = False, 

459 ) -> np.ndarray: 

460 """ 

461 Perform a forward pass of the quantum circuit. 

462 

463 Args: 

464 params (Optional[np.ndarray]): Weight vector of shape 

465 [n_layers, n_qubits*n_params_per_layer]. 

466 If None, model internal parameters are used. 

467 inputs (Optional[np.ndarray]): Input vector of shape [1]. 

468 If None, zeros are used. 

469 noise_params (Optional[Dict[str, float]], optional): The noise parameters. 

470 Defaults to None which results in the last 

471 set noise parameters being used. 

472 cache (Optional[bool], optional): Whether to cache the results. 

473 Defaults to False. 

474 execution_type (str, optional): The type of execution. 

475 Must be one of 'expval', 'density', or 'probs'. 

476 Defaults to None which results in the last set execution type 

477 being used. 

478 

479 Returns: 

480 np.ndarray: The output of the quantum circuit. 

481 The shape depends on the execution_type. 

482 - If execution_type is 'expval', returns an ndarray of shape 

483 (1,) if output_qubit is -1, else (len(output_qubit),). 

484 - If execution_type is 'density', returns an ndarray 

485 of shape (2**n_qubits, 2**n_qubits). 

486 - If execution_type is 'probs', returns an ndarray 

487 of shape (2**n_qubits,) if output_qubit is -1, else 

488 (2**len(output_qubit),). 

489 """ 

490 # Call forward method which handles the actual caching etc. 

491 return self._forward( 

492 params=params, 

493 inputs=inputs, 

494 noise_params=noise_params, 

495 cache=cache, 

496 execution_type=execution_type, 

497 force_mean=force_mean, 

498 ) 

499 

500 def _inputs_validation( 

501 self, inputs: Union[None, List, float, int, np.ndarray] 

502 ) -> np.ndarray: 

503 """ 

504 Validate the inputs to be a 2D numpy array of shape (batch_size, n_inputs). 

505 

506 Args: 

507 inputs (Union[None, List, float, int, np.ndarray]): The input to validate. 

508 

509 Returns: 

510 np.ndarray: The validated input. 

511 """ 

512 if inputs is None: 

513 # initialize to zero 

514 inputs = np.array([[0]]) 

515 elif isinstance(inputs, List): 

516 inputs = np.stack(inputs) 

517 elif isinstance(inputs, float) or isinstance(inputs, int): 

518 inputs = np.array([inputs]) 

519 

520 if len(inputs.shape) == 1: 

521 if isinstance(self._enc, List): 

522 inputs = inputs.reshape(-1, 1) 

523 else: 

524 # add a batch dimension 

525 inputs = inputs.reshape(inputs.shape[0], 1) 

526 

527 return inputs 

528 

529 def _forward( 

530 self, 

531 params: Optional[np.ndarray] = None, 

532 inputs: Optional[np.ndarray] = None, 

533 noise_params: Optional[Dict[str, float]] = None, 

534 cache: Optional[bool] = False, 

535 execution_type: Optional[str] = None, 

536 force_mean: Optional[bool] = False, 

537 ) -> np.ndarray: 

538 """ 

539 Perform a forward pass of the quantum circuit. 

540 

541 Args: 

542 params (Optional[np.ndarray]): Weight vector of shape 

543 [n_layers, n_qubits*n_params_per_layer]. 

544 If None, model internal parameters are used. 

545 inputs (Optional[np.ndarray]): Input vector of shape [1]. 

546 If None, zeros are used. 

547 noise_params (Optional[Dict[str, float]], optional): The noise parameters. 

548 Defaults to None which results in the last 

549 set noise parameters being used. 

550 cache (Optional[bool], optional): Whether to cache the results. 

551 Defaults to False. 

552 execution_type (str, optional): The type of execution. 

553 Must be one of 'expval', 'density', or 'probs'. 

554 Defaults to None which results in the last set execution type 

555 being used. 

556 

557 Returns: 

558 np.ndarray: The output of the quantum circuit. 

559 The shape depends on the execution_type. 

560 - If execution_type is 'expval', returns an ndarray of shape 

561 (1,) if output_qubit is -1, else (len(output_qubit),). 

562 - If execution_type is 'density', returns an ndarray 

563 of shape (2**n_qubits, 2**n_qubits). 

564 - If execution_type is 'probs', returns an ndarray 

565 of shape (2**n_qubits,) if output_qubit is -1, else 

566 (2**len(output_qubit),). 

567 

568 Raises: 

569 NotImplementedError: If the number of shots is not None or if the 

570 expectation value is True. 

571 """ 

572 # set the parameters as object attributes 

573 if noise_params is not None: 

574 self.noise_params = noise_params 

575 if execution_type is not None: 

576 self.execution_type = execution_type 

577 

578 if params is None: 

579 params = self.params 

580 else: 

581 if numpy_boxes.ArrayBox == type(params): 

582 self.params = params._value 

583 else: 

584 self.params = params 

585 

586 inputs = self._inputs_validation(inputs) 

587 

588 # the qasm representation contains the bound parameters, 

589 # thus it is ok to hash that 

590 hs = hashlib.md5( 

591 repr( 

592 { 

593 "n_qubits": self.n_qubits, 

594 "n_layers": self.n_layers, 

595 "pqc": self.pqc.__class__.__name__, 

596 "dru": self.data_reupload, 

597 "params": self.params, # use safe-params 

598 "noise_params": self.noise_params, 

599 "execution_type": self.execution_type, 

600 "inputs": inputs, 

601 "output_qubit": self.output_qubit, 

602 } 

603 ).encode("utf-8") 

604 ).hexdigest() 

605 

606 result: Optional[np.ndarray] = None 

607 if cache: 

608 name: str = f"pqc_{hs}.npy" 

609 

610 cache_folder: str = ".cache" 

611 if not os.path.exists(cache_folder): 

612 os.mkdir(cache_folder) 

613 

614 file_path: str = os.path.join(cache_folder, name) 

615 

616 if os.path.isfile(file_path): 

617 result = np.load(file_path) 

618 

619 if result is None: 

620 # if density matrix requested or noise params used 

621 if self.execution_type == "density" or self.noise_params is not None: 

622 result = self.circuit_mixed( 

623 params=params, # use arraybox params 

624 inputs=inputs, 

625 ) 

626 else: 

627 if isinstance(self.circuit, qml.qnn.torch.TorchLayer): 

628 result = self.circuit( 

629 inputs=inputs, 

630 ) 

631 else: 

632 result = self.circuit( 

633 params=params, # use arraybox params 

634 inputs=inputs, 

635 ) 

636 

637 if isinstance(result, list): 

638 result = np.stack(result) 

639 

640 if self.execution_type == "expval" and self.output_qubit == -1: 

641 

642 # Calculating mean value after stacking, to not 

643 # discard gradient information 

644 if force_mean: 

645 # exception for torch layer because it swaps batch and output dimension 

646 if isinstance(self.circuit, qml.qnn.torch.TorchLayer): 

647 result = result.mean(axis=-1) 

648 else: 

649 result = result.mean(axis=0) 

650 

651 if len(result.shape) == 3 and result.shape[0] == 1: 

652 result = result[0] 

653 

654 if cache: 

655 np.save(file_path, result) 

656 

657 return result