qadence 1.10.2__py3-none-any.whl → 1.11.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.
@@ -20,6 +20,7 @@ from qadence.types import (
20
20
  ReuploadScaling,
21
21
  Strategy,
22
22
  )
23
+ from torch import dtype
23
24
 
24
25
  logger = getLogger(__file__)
25
26
 
@@ -116,10 +117,9 @@ class TrainConfig:
116
117
  """The log folder for saving checkpoints and tensorboard logs.
117
118
 
118
119
  This stores the path where all logs and checkpoints are being saved
119
- for this training session. `log_folder` takes precedence over `root_folder` and
120
- `create_subfolder_per_run` arguments. If the user specifies a log_folder,
121
- all checkpoints will be saved in this folder and `root_folder` argument
122
- will not be used.
120
+ for this training session. `log_folder` takes precedence over `root_folder`,
121
+ but it is ignored if `create_subfolders_per_run=True` (in which case, subfolders
122
+ will be spawned in the root folder).
123
123
  """
124
124
 
125
125
  checkpoint_best_only: bool = False
@@ -195,7 +195,7 @@ class TrainConfig:
195
195
  plots that are logged or saved at specified intervals.
196
196
  """
197
197
 
198
- _subfolders: list = field(default_factory=list)
198
+ _subfolders: list[str] = field(default_factory=list)
199
199
  """List of subfolders used for logging different runs using the same config inside the.
200
200
 
201
201
  root folder.
@@ -203,6 +203,67 @@ class TrainConfig:
203
203
  Each subfolder is of structure `<id>_<timestamp>_<PID>`.
204
204
  """
205
205
 
206
+ nprocs: int = 1
207
+ """
208
+ The number of processes to use for training when spawning subprocesses.
209
+
210
+ For effective parallel processing, set this to a value greater than 1.
211
+ - In case of Multi-GPU or Multi-Node-Multi-GPU setups, nprocs should be equal to
212
+ the total number of GPUs across all nodes (world size), or total number of GPU to be used.
213
+
214
+ If nprocs > 1, multiple processes will be spawned for training. The training framework will launch
215
+ additional processes (e.g., for distributed or parallel training).
216
+ - For CPU setup, this will launch a true parallel processes
217
+ - For GPU setup, this will launch a distributed training routine.
218
+ This uses the DistributedDataParallel framework from PyTorch.
219
+ """
220
+
221
+ compute_setup: str = "cpu"
222
+ """
223
+ Compute device setup; options are "auto", "gpu", or "cpu".
224
+
225
+ - "auto": Automatically uses GPU if available; otherwise, falls back to CPU.
226
+ - "gpu": Forces GPU usage, raising an error if no CUDA device is available.
227
+ - "cpu": Forces the use of CPU regardless of GPU availability.
228
+ """
229
+
230
+ backend: str = "gloo"
231
+ """
232
+ Backend used for distributed training communication.
233
+
234
+ The default is "gloo". Other options may include "nccl" - which is optimized for GPU-based training or "mpi",
235
+ depending on your system and requirements.
236
+ It should be one of the backends supported by `torch.distributed`. For further details, please look at
237
+ [torch backends](https://pytorch.org/docs/stable/distributed.html#torch.distributed.Backend)
238
+ """
239
+
240
+ log_setup: str = "cpu"
241
+ """
242
+ Logging device setup; options are "auto" or "cpu".
243
+
244
+ - "auto": Uses the same device for logging as for computation.
245
+ - "cpu": Forces logging to occur on the CPU. This can be useful to avoid potential conflicts with GPU processes.
246
+ """
247
+
248
+ dtype: dtype | None = None
249
+ """
250
+ Data type (precision) for computations.
251
+
252
+ Both model parameters, and dataset will be of the provided precision.
253
+
254
+ If not specified or None, the default torch precision (usually torch.float32) is used.
255
+ If provided dtype is torch.complex128, model parameters will be torch.complex128, and data parameters will be torch.float64
256
+ """
257
+
258
+ all_reduce_metrics: bool = False
259
+ """
260
+ Whether to aggregate metrics (e.g., loss, accuracy) across processes.
261
+
262
+ When True, metrics from different training processes are averaged to provide a consolidated metrics.
263
+ Note: Since aggregation requires synchronization/all_reduce operation, this can increase the
264
+ computation time significantly.
265
+ """
266
+
206
267
 
207
268
  @dataclass
208
269
  class FeatureMapConfig:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import Callable
3
4
  import numpy as np
4
5
  from sympy import Basic
5
6
 
@@ -24,7 +25,6 @@ from qadence.constructors.iia import iia
24
25
  from qadence.measurements import Measurements
25
26
  from qadence.noise import NoiseHandler
26
27
  from qadence.operations import CNOT, RX, RY, I, N, Z
27
- from qadence.parameters import Parameter
28
28
  from qadence.register import Register
29
29
  from qadence.types import (
30
30
  AnsatzType,
@@ -33,10 +33,10 @@ from qadence.types import (
33
33
  InputDiffMode,
34
34
  Interaction,
35
35
  MultivariateStrategy,
36
- ObservableTransform,
37
36
  ReuploadScaling,
38
37
  Strategy,
39
38
  TParameter,
39
+ TArray,
40
40
  )
41
41
 
42
42
  from .config import AnsatzConfig, FeatureMapConfig
@@ -706,35 +706,6 @@ def _interleave_ansatz_in_fm(
706
706
  return chain(*full_fm)
707
707
 
708
708
 
709
- def load_observable_transformations(config: ObservableConfig) -> tuple[Parameter, Parameter]:
710
- """
711
- Get the observable shifting and scaling factors.
712
-
713
- Args:
714
- config (ObservableConfig): Observable configuration.
715
-
716
- Returns:
717
- tuple[Parameter, Parameter]: The observable shifting and scaling factors.
718
- """
719
- shift = config.shift
720
- scale = config.scale
721
- if config.trainable_transform is not None:
722
- shift = Parameter(name=shift, trainable=config.trainable_transform)
723
- scale = Parameter(name=scale, trainable=config.trainable_transform)
724
- else:
725
- shift = Parameter(shift)
726
- scale = Parameter(scale)
727
- return scale, shift
728
-
729
-
730
- ObservableTransformMap = {
731
- ObservableTransform.RANGE: lambda detuning, scale, shift: (
732
- (shift, shift - scale) if detuning is N else (0.5 * (shift - scale), 0.5 * (scale + shift))
733
- ),
734
- ObservableTransform.SCALE: lambda _, scale, shift: (scale, shift),
735
- }
736
-
737
-
738
709
  def _global_identity(register: int | Register) -> KronBlock:
739
710
  """Create a global identity block."""
740
711
  return kron(
@@ -742,7 +713,7 @@ def _global_identity(register: int | Register) -> KronBlock:
742
713
  )
743
714
 
744
715
 
745
- def observable_from_config(
716
+ def create_observable(
746
717
  register: int | Register,
747
718
  config: ObservableConfig,
748
719
  ) -> AbstractBlock:
@@ -756,35 +727,11 @@ def observable_from_config(
756
727
  Returns:
757
728
  AbstractBlock: The observable block.
758
729
  """
759
- scale, shift = load_observable_transformations(config)
760
- return create_observable(register, config.detuning, scale, shift, config.transformation_type)
761
-
762
-
763
- def create_observable(
764
- register: int | Register,
765
- detuning: TDetuning = Z,
766
- scale: TParameter | None = None,
767
- shift: TParameter | None = None,
768
- transformation_type: ObservableTransform = ObservableTransform.NONE, # type: ignore[assignment]
769
- ) -> AbstractBlock:
770
- """
771
- Create an observable block.
772
-
773
- Args:
774
- register (int | Register): Number of qubits or a register object.
775
- detuning: The type of detuning.
776
- scale: A parameter for the scale.
777
- shift: A parameter for the shift.
778
-
779
- Returns:
780
- AbstractBlock: The observable block.
781
- """
782
- if transformation_type == ObservableTransform.RANGE:
783
- scale, shift = ObservableTransformMap[transformation_type](detuning, scale, shift) # type: ignore[index]
784
- shifting_term: AbstractBlock = shift * _global_identity(register) # type: ignore[operator]
785
- detuning_hamiltonian: AbstractBlock = scale * hamiltonian_factory( # type: ignore[operator]
730
+ shifting_term: AbstractBlock = config.shift * _global_identity(register) # type: ignore[operator]
731
+ detuning_hamiltonian: AbstractBlock = config.scale * hamiltonian_factory( # type: ignore[operator]
786
732
  register=register,
787
- detuning=detuning,
733
+ interaction=config.interaction,
734
+ detuning=config.detuning,
788
735
  )
789
736
  return add(shifting_term, detuning_hamiltonian)
790
737
 
@@ -844,9 +791,9 @@ def build_qnn_from_configs(
844
791
  circ = QuantumCircuit(register, *blocks)
845
792
 
846
793
  observable: AbstractBlock | list[AbstractBlock] = (
847
- [observable_from_config(register=register, config=cfg) for cfg in observable_config]
794
+ [create_observable(register=register, config=cfg) for cfg in observable_config]
848
795
  if isinstance(observable_config, list)
849
- else observable_from_config(register=register, config=observable_config)
796
+ else create_observable(register=register, config=observable_config)
850
797
  )
851
798
 
852
799
  ufa = QNN(
qadence/ml_tools/data.py CHANGED
@@ -34,6 +34,10 @@ class OptimizeResult:
34
34
  """Metrics that can be saved during training."""
35
35
  extra: dict = field(default_factory=lambda: dict())
36
36
  """Extra dict for saving anything else to be used in callbacks."""
37
+ rank: int = 0
38
+ """Rank of the process for which this result was generated."""
39
+ device: str | None = "cpu"
40
+ """Device on which this result for calculated."""
37
41
 
38
42
 
39
43
  @dataclass
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ from .information_content import InformationContent
@@ -0,0 +1,339 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from logging import getLogger
5
+ from math import log, sqrt
6
+ from statistics import NormalDist
7
+ from typing import Any, Callable
8
+
9
+ import torch
10
+ from torch import nn
11
+ from torch.func import functional_call # type: ignore
12
+
13
+ logger = getLogger("ml_tools")
14
+
15
+
16
+ class InformationContent:
17
+ def __init__(
18
+ self,
19
+ model: nn.Module,
20
+ loss_fn: Callable,
21
+ xs: Any,
22
+ epsilons: torch.Tensor,
23
+ variation_multiple: int = 20,
24
+ ) -> None:
25
+ """Information Landscape class.
26
+
27
+ This class handles the study of loss landscape from information theoretic
28
+ perspective and provides methods to get bounds on the norm of the
29
+ gradient from the Information Content of the loss landscape.
30
+
31
+ Args:
32
+ model: The quantum or classical model to analyze.
33
+ loss_fn: Loss function that takes model output and calculates loss
34
+ xs: Input data to evaluate the model on
35
+ epsilons: The thresholds to use for discretization of the finite derivatives
36
+ variation_multiple: The number of sets of variational parameters to generate per each
37
+ variational parameter. The number of variational parameters required for the
38
+ statistical analysis scales linearly with the amount of them present in the
39
+ model. This is that linear factor.
40
+
41
+ Notes:
42
+ This class provides flexibility in terms of what the model, the loss function,
43
+ and the xs are. The only requirement is that the loss_fn takes the model and xs as
44
+ arguments and returns the loss, and another dictionary of other metrics.
45
+
46
+ Thus, assumed structure:
47
+ loss_fn(model, xs) -> (loss, metrics, ...)
48
+
49
+ Example: A Classifier
50
+ ```python
51
+ model = nn.Linear(10, 1)
52
+
53
+ def loss_fn(
54
+ model: nn.Module,
55
+ xs: tuple[torch.Tensor, torch.Tensor]
56
+ ) -> tuple[torch.Tensor, dict[str, float]:
57
+ criterion = nn.MSELoss()
58
+ inputs, labels = xs
59
+ outputs = model(inputs)
60
+ loss = criterion(outputs, labels)
61
+ metrics = {"loss": loss.item()}
62
+ return loss, metrics
63
+
64
+ xs = (torch.randn(10, 10), torch.randn(10, 1))
65
+
66
+ info_landscape = InfoLandscape(model, loss_fn, xs)
67
+ ```
68
+ In this example, the model is a linear classifier, and the `xs` include both the
69
+ inputs and the target labels. The logic for calculation of the loss from this lies
70
+ entirely within the `loss_fn` function. This can then further be used to obtain the
71
+ bounds on the average norm of the gradient of the loss function.
72
+
73
+ Example: A Physics Informed Neural Network
74
+ ```python
75
+ class PhysicsInformedNN(nn.Module):
76
+ // <Initialization Logic>
77
+
78
+ def forward(self, xs: dict[str, torch.Tensor]):
79
+ return {
80
+ "pde_residual": pde_residual(xs["pde"]),
81
+ "boundary_condition": bc_term(xs["bc"]),
82
+ }
83
+
84
+ def loss_fn(
85
+ model: PhysicsInformedNN,
86
+ xs: dict[str, torch.Tensor]
87
+ ) -> tuple[torch.Tensor, dict[str, float]:
88
+ pde_residual, bc_term = model(xs)
89
+ loss = torch.mean(torch.sum(pde_residual**2, dim=1), dim=0)
90
+ + torch.mean(torch.sum(bc_term**2, dim=1), dim=0)
91
+
92
+ return loss, {"pde_residual": pde_residual, "bc_term": bc_term}
93
+
94
+ xs = {
95
+ "pde": torch.linspace(0, 1, 10),
96
+ "bc": torch.tensor([0.0]),
97
+ }
98
+
99
+ info_landscape = InfoLandscape(model, loss_fn, xs)
100
+ ```
101
+
102
+ In this example, the model is a Physics Informed Neural Network, and the `xs`
103
+ are the inputs to the different residual components of the model. The logic
104
+ for calculation of the residuals lies within the PhysicsInformedNN class, and
105
+ the loss function is defined to calculate the loss that is to be optimized
106
+ from these residuals. This can then further be used to obtain the
107
+ bounds on the average norm of the gradient of the loss function.
108
+
109
+ The first value that the `loss_fn` returns is the loss value that is being optimized.
110
+ The function is also expected to return other value(s), often the metrics that are
111
+ used to calculate the loss. These values are ignored for the purpose of this class.
112
+ """
113
+ self.model = model
114
+ self.loss_fn = loss_fn
115
+ self.xs = xs
116
+ self.epsilons = epsilons
117
+ self.device = next(model.parameters()).device
118
+
119
+ self.param_shapes = {}
120
+ self.total_params = 0
121
+
122
+ for name, param in model.named_parameters():
123
+ self.param_shapes[name] = param.shape
124
+ self.total_params += param.numel()
125
+ self.n_variations = variation_multiple * self.total_params
126
+ self.all_variations = torch.empty(
127
+ (self.n_variations, self.total_params), device=self.device
128
+ ).uniform_(0, 2 * torch.pi)
129
+
130
+ def reshape_param_variations(self) -> dict[str, torch.Tensor]:
131
+ """Reshape variations of the model's variational parameters.
132
+
133
+ Returns:
134
+ Dictionary of parameter tensors, each with shape [n_variations, *param_shape]
135
+ """
136
+ param_variations = {}
137
+ start_idx = 0
138
+
139
+ for name, shape in self.param_shapes.items():
140
+ param_size = torch.prod(torch.tensor(shape)).item()
141
+ param_variations[name] = self.all_variations[
142
+ :, start_idx : start_idx + param_size
143
+ ].view(self.n_variations, *shape)
144
+ start_idx += param_size
145
+
146
+ return param_variations
147
+
148
+ def batched_loss(self) -> torch.Tensor:
149
+ """Calculate loss for all parameter variations in a batched manner.
150
+
151
+ Returns: Tensor of loss values for each parameter variation
152
+ """
153
+ param_variations = self.reshape_param_variations()
154
+ losses = torch.zeros(self.n_variations, device=self.device)
155
+
156
+ for i in range(self.n_variations):
157
+ params = {name: param[i] for name, param in param_variations.items()}
158
+ current_model = lambda x: functional_call(self.model, params, (x,))
159
+ losses[i] = self.loss_fn(current_model, self.xs)[0]
160
+
161
+ return losses
162
+
163
+ def randomized_finite_der(self) -> torch.Tensor:
164
+ """
165
+ Calculate normalized finite difference of loss on doing random walk in the parameter space.
166
+
167
+ This serves as a proxy for the derivative of the loss with respect to parameters.
168
+
169
+ Returns:
170
+ Tensor containing normalized finite differences (approximate directional derivatives)
171
+ between consecutive points in the random walk. Shape: [n_variations - 1]
172
+ """
173
+ losses = self.batched_loss()
174
+
175
+ return (losses[1:] - losses[:-1]) / (
176
+ torch.norm(self.all_variations[1:] - self.all_variations[:-1], dim=1) + 1e-8
177
+ )
178
+
179
+ def discretize_derivatives(self) -> torch.Tensor:
180
+ """
181
+ Convert finite derivatives into discrete values.
182
+
183
+ Returns:
184
+ Tensor containing discretized derivatives with shape [n_epsilons, n_variations-2]
185
+ Each row contains {-1, 0, 1} values for that epsilon
186
+ """
187
+ derivatives = self.randomized_finite_der()
188
+
189
+ derivatives = derivatives.unsqueeze(0)
190
+ epsilons = self.epsilons.unsqueeze(1)
191
+
192
+ discretized = torch.zeros((len(epsilons), len(derivatives[0])), device=self.device)
193
+ discretized[derivatives > epsilons] = 1
194
+ discretized[derivatives < -epsilons] = -1
195
+
196
+ return discretized
197
+
198
+ def calculate_transition_probabilities_batch(self) -> torch.Tensor:
199
+ """
200
+ Calculate transition probabilities for multiple epsilon values.
201
+
202
+ Returns:
203
+ Tensor of shape [n_epsilons, 6] containing probabilities for each transition type
204
+ Columns order: [+1to0, +1to-1, 0to+1, 0to-1, -1to0, -1to+1]
205
+ """
206
+ discretized = self.discretize_derivatives()
207
+
208
+ current = discretized[:, :-1]
209
+ next_val = discretized[:, 1:]
210
+
211
+ transitions = torch.stack(
212
+ [
213
+ ((current == 1) & (next_val == 0)).sum(dim=1),
214
+ ((current == 1) & (next_val == -1)).sum(dim=1),
215
+ ((current == 0) & (next_val == 1)).sum(dim=1),
216
+ ((current == 0) & (next_val == -1)).sum(dim=1),
217
+ ((current == -1) & (next_val == 0)).sum(dim=1),
218
+ ((current == -1) & (next_val == 1)).sum(dim=1),
219
+ ],
220
+ dim=1,
221
+ ).float()
222
+
223
+ total_transitions = current.size(1)
224
+ probabilities = transitions / total_transitions
225
+
226
+ return probabilities
227
+
228
+ @functools.cached_property
229
+ def calculate_IC(self) -> torch.Tensor:
230
+ """
231
+ Calculate Information Content for multiple epsilon values.
232
+
233
+ Returns: Tensor of IC values for each epsilon [n_epsilons]
234
+ """
235
+ probs = self.calculate_transition_probabilities_batch()
236
+
237
+ mask = probs > 1e-4
238
+
239
+ ic_terms = torch.where(mask, -probs * torch.log(probs), torch.zeros_like(probs))
240
+ ic_values = ic_terms.sum(dim=1) / torch.log(torch.tensor(6.0))
241
+
242
+ return ic_values
243
+
244
+ def max_IC(self) -> tuple[float, float]:
245
+ """
246
+ Get the maximum Information Content and its corresponding epsilon.
247
+
248
+ Returns: Tuple of (maximum IC value, optimal epsilon)
249
+ """
250
+ max_ic, max_idx = torch.max(self.calculate_IC, dim=0)
251
+ max_epsilon = self.epsilons[max_idx]
252
+ return max_ic.item(), max_epsilon.item()
253
+
254
+ def sensitivity_IC(self, eta: float) -> float:
255
+ """
256
+ Find the minimum value of epsilon such that the information content is less than eta.
257
+
258
+ Args:
259
+ eta: Threshold value, the sensitivity IC.
260
+
261
+ Returns: The epsilon value that gives IC that is less than the sensitivity IC.
262
+ """
263
+ ic_values = self.calculate_IC
264
+ mask = ic_values < eta
265
+ epsilons = self.epsilons[mask]
266
+ return float(epsilons.min().item())
267
+
268
+ @staticmethod
269
+ @functools.lru_cache
270
+ def q_value(H_value: float) -> float:
271
+ """
272
+ Compute the q value.
273
+
274
+ q is the solution to the equation:
275
+ H(x) = 4h(x) + 2h(1/2 - 2x)
276
+
277
+ It is the value of the probability of 4 of the 6 transitions such that
278
+ the IC is the same as the IC of our system.
279
+
280
+ This quantity is useful in calculating the bounds on the norms of the gradients.
281
+
282
+ Args:
283
+ H_value (float): The information content.
284
+
285
+ Returns:
286
+ float: The q value
287
+ """
288
+
289
+ x = torch.linspace(0.001, 0.16667, 10000)
290
+
291
+ H = -4 * x * torch.log(x) / torch.log(torch.tensor(6)) - 2 * (0.5 - 2 * x) * torch.log(
292
+ 0.5 - 2 * x
293
+ ) / torch.log(torch.tensor(6))
294
+ err = torch.abs(H - H_value)
295
+ idx = torch.argmin(err)
296
+ return float(x[idx].item())
297
+
298
+ def get_grad_norm_bounds_max_IC(self) -> tuple[float, float]:
299
+ """
300
+ Compute the bounds on the average norm of the gradient.
301
+
302
+ Returns:
303
+ tuple[Tensor, Tensor]: The lower and upper bounds.
304
+ """
305
+ max_IC, epsilon_m = self.max_IC()
306
+ lower_bound = (
307
+ epsilon_m
308
+ * sqrt(self.total_params)
309
+ / (NormalDist().inv_cdf(1 - 2 * self.q_value(max_IC)))
310
+ )
311
+ upper_bound = (
312
+ epsilon_m
313
+ * sqrt(self.total_params)
314
+ / (NormalDist().inv_cdf(0.5 * (1 + 2 * self.q_value(max_IC))))
315
+ )
316
+
317
+ if max_IC < log(2, 6):
318
+ logger.warning(
319
+ "Warning: The maximum IC is less than the required value. The bounds may be"
320
+ + " inaccurate."
321
+ )
322
+
323
+ return lower_bound, upper_bound
324
+
325
+ def get_grad_norm_bounds_sensitivity_IC(self, eta: float) -> float:
326
+ """
327
+ Compute the bounds on the average norm of the gradient.
328
+
329
+ Args:
330
+ eta (float): The sensitivity IC.
331
+
332
+ Returns:
333
+ Tensor: The lower bound.
334
+ """
335
+ epsilon_sensitivity = self.sensitivity_IC(eta)
336
+ upper_bound = (
337
+ epsilon_sensitivity * sqrt(self.total_params) / (NormalDist().inv_cdf(1 - 3 * eta / 2))
338
+ )
339
+ return upper_bound
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections import Counter
3
+ from collections import Counter, OrderedDict
4
4
  from logging import getLogger
5
5
  from typing import Any, Callable
6
6
 
@@ -19,6 +19,7 @@ from qadence.model import QuantumModel
19
19
  from qadence.noise import NoiseHandler
20
20
  from qadence.register import Register
21
21
  from qadence.types import BackendName, DiffMode, Endianness, InputDiffMode, ParamDictType
22
+ from qadence.utils import block_to_mathematical_expression
22
23
 
23
24
  logger = getLogger(__name__)
24
25
 
@@ -208,6 +209,8 @@ class QNN(QuantumModel):
208
209
  else:
209
210
  raise ValueError(f"Unkown forward diff mode: {self.input_diff_mode}")
210
211
 
212
+ self._model_configs: dict = dict()
213
+
211
214
  @classmethod
212
215
  def from_configs(
213
216
  cls,
@@ -255,7 +258,7 @@ class QNN(QuantumModel):
255
258
  from qadence.constructors import ObservableConfig
256
259
  from qadence.operations import Z
257
260
  from qadence.types import (
258
- AnsatzType, BackendName, BasisSet, ObservableTransform, ReuploadScaling, Strategy
261
+ AnsatzType, BackendName, BasisSet, ReuploadScaling, Strategy
259
262
  )
260
263
 
261
264
  register = 4
@@ -263,7 +266,6 @@ class QNN(QuantumModel):
263
266
  detuning=Z,
264
267
  scale=5.0,
265
268
  shift=0.0,
266
- transformation_type=ObservableTransform.SCALE,
267
269
  trainable_transform=None,
268
270
  )
269
271
  fm_config = FeatureMapConfig(
@@ -293,7 +295,7 @@ class QNN(QuantumModel):
293
295
  """
294
296
  from .constructors import build_qnn_from_configs
295
297
 
296
- return build_qnn_from_configs(
298
+ qnn = build_qnn_from_configs(
297
299
  register=register,
298
300
  observable_config=obs_config,
299
301
  fm_config=fm_config,
@@ -305,6 +307,69 @@ class QNN(QuantumModel):
305
307
  configuration=configuration,
306
308
  input_diff_mode=input_diff_mode,
307
309
  )
310
+ qnn._model_configs = {
311
+ "register": register,
312
+ "observable_config": obs_config,
313
+ "fm_config": fm_config,
314
+ "ansatz_config": ansatz_config,
315
+ }
316
+ return qnn
317
+
318
+ def __str__(self) -> str | Any:
319
+ """Return a string representation of a QNN.
320
+
321
+ When creating a QNN from a set of configurations,
322
+ we print the configurations used. Otherwise, we use the default printing.
323
+
324
+ Returns:
325
+ str | Any: A string representation of a QNN.
326
+
327
+ Example:
328
+ ```python exec="on" source="material-block" result="json"
329
+ from qadence import QNN
330
+ from qadence.constructors.hamiltonians import Interaction
331
+ from qadence.ml_tools.config import AnsatzConfig, FeatureMapConfig
332
+ from qadence.ml_tools.constructors import (
333
+ ObservableConfig,
334
+ )
335
+ from qadence.operations import Z
336
+ from qadence.types import BackendName
337
+
338
+ backend = BackendName.PYQTORCH
339
+ fm_config = FeatureMapConfig(num_features=1)
340
+ ansatz_config = AnsatzConfig()
341
+ observable_config = ObservableConfig(detuning=Z, interaction=Interaction.ZZ, scale=2)
342
+
343
+ qnn = QNN.from_configs(
344
+ register=2,
345
+ obs_config=observable_config,
346
+ fm_config=fm_config,
347
+ ansatz_config=ansatz_config,
348
+ backend=backend,
349
+ )
350
+ print(qnn) # markdown-exec: hide
351
+ ```
352
+ """
353
+ if bool(self._model_configs):
354
+ configs_str = "\n".join(
355
+ (
356
+ k + " = " + str(self._model_configs[k])
357
+ for k in sorted(self._model_configs.keys())
358
+ if k != "observable_config"
359
+ )
360
+ )
361
+ observable_str = ""
362
+ if self._observable:
363
+ observable_str = (
364
+ "observable_config = [\n"
365
+ + "\n".join(
366
+ (block_to_mathematical_expression(obs.original) for obs in self._observable)
367
+ )
368
+ + "\n]"
369
+ )
370
+ return f"{type(self).__name__}(\n{configs_str}\n{observable_str}\n)"
371
+
372
+ return super().__str__()
308
373
 
309
374
  def forward(
310
375
  self,