qilisdk 0.1.4__py3-none-any.whl → 0.1.6__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.
Files changed (86) hide show
  1. qilisdk/__init__.py +11 -2
  2. qilisdk/__init__.pyi +2 -3
  3. qilisdk/_logging.py +135 -0
  4. qilisdk/_optionals.py +5 -7
  5. qilisdk/analog/__init__.py +3 -18
  6. qilisdk/analog/exceptions.py +2 -4
  7. qilisdk/analog/hamiltonian.py +455 -110
  8. qilisdk/analog/linear_schedule.py +121 -0
  9. qilisdk/analog/schedule.py +275 -79
  10. qilisdk/{extras → backends}/__init__.py +9 -4
  11. qilisdk/{common/model.py → backends/__init__.pyi} +3 -1
  12. qilisdk/backends/backend.py +117 -0
  13. qilisdk/{extras/cuda → backends}/cuda_backend.py +152 -159
  14. qilisdk/backends/qutip_backend.py +473 -0
  15. qilisdk/core/__init__.py +63 -0
  16. qilisdk/{common → core}/algorithm.py +2 -1
  17. qilisdk/{extras/qaas/qaas_settings.py → core/exceptions.py} +12 -6
  18. qilisdk/core/model.py +1034 -0
  19. qilisdk/core/parameterizable.py +75 -0
  20. qilisdk/core/qtensor.py +666 -0
  21. qilisdk/{common → core}/result.py +2 -1
  22. qilisdk/core/variables.py +1969 -0
  23. qilisdk/cost_functions/__init__.py +18 -0
  24. qilisdk/cost_functions/cost_function.py +77 -0
  25. qilisdk/cost_functions/model_cost_function.py +145 -0
  26. qilisdk/cost_functions/observable_cost_function.py +109 -0
  27. qilisdk/digital/__init__.py +3 -22
  28. qilisdk/digital/ansatz.py +200 -160
  29. qilisdk/digital/circuit.py +81 -9
  30. qilisdk/digital/exceptions.py +12 -6
  31. qilisdk/digital/gates.py +229 -86
  32. qilisdk/{extras/qaas/qaas_analog_result.py → functionals/__init__.py} +14 -5
  33. qilisdk/functionals/functional.py +39 -0
  34. qilisdk/{common/backend.py → functionals/functional_result.py} +3 -1
  35. qilisdk/functionals/sampling.py +81 -0
  36. qilisdk/functionals/sampling_result.py +92 -0
  37. qilisdk/functionals/time_evolution.py +98 -0
  38. qilisdk/functionals/time_evolution_result.py +84 -0
  39. qilisdk/functionals/variational_program.py +80 -0
  40. qilisdk/functionals/variational_program_result.py +69 -0
  41. qilisdk/logging_config.yaml +16 -0
  42. qilisdk/{common → optimizers}/__init__.py +1 -1
  43. qilisdk/optimizers/optimizer.py +39 -0
  44. qilisdk/{common → optimizers}/optimizer_result.py +3 -12
  45. qilisdk/{common/optimizer.py → optimizers/scipy_optimizer.py} +10 -28
  46. qilisdk/settings.py +78 -0
  47. qilisdk/speqtrum/__init__.py +41 -0
  48. qilisdk/{extras → speqtrum}/__init__.pyi +3 -3
  49. qilisdk/speqtrum/experiments/__init__.py +25 -0
  50. qilisdk/speqtrum/experiments/experiment_functional.py +124 -0
  51. qilisdk/speqtrum/experiments/experiment_result.py +231 -0
  52. qilisdk/{extras/qaas → speqtrum}/keyring.py +8 -4
  53. qilisdk/speqtrum/speqtrum.py +587 -0
  54. qilisdk/speqtrum/speqtrum_models.py +467 -0
  55. qilisdk/utils/__init__.py +0 -14
  56. qilisdk/utils/openqasm2.py +1 -1
  57. qilisdk/utils/serialization.py +1 -1
  58. qilisdk/utils/visualization/PlusJakartaSans-SemiBold.ttf +0 -0
  59. qilisdk/utils/visualization/__init__.py +24 -0
  60. qilisdk/utils/visualization/circuit_renderers.py +781 -0
  61. qilisdk/utils/visualization/schedule_renderers.py +166 -0
  62. qilisdk/utils/visualization/style.py +154 -0
  63. qilisdk/utils/visualization/themes.py +76 -0
  64. qilisdk/yaml.py +126 -0
  65. {qilisdk-0.1.4.dist-info → qilisdk-0.1.6.dist-info}/METADATA +186 -140
  66. qilisdk-0.1.6.dist-info/RECORD +69 -0
  67. qilisdk/analog/algorithms.py +0 -111
  68. qilisdk/analog/analog_backend.py +0 -43
  69. qilisdk/analog/analog_result.py +0 -114
  70. qilisdk/analog/quantum_objects.py +0 -596
  71. qilisdk/digital/digital_algorithm.py +0 -20
  72. qilisdk/digital/digital_backend.py +0 -90
  73. qilisdk/digital/digital_result.py +0 -145
  74. qilisdk/digital/vqe.py +0 -166
  75. qilisdk/extras/cuda/__init__.py +0 -13
  76. qilisdk/extras/cuda/cuda_analog_result.py +0 -19
  77. qilisdk/extras/cuda/cuda_digital_result.py +0 -19
  78. qilisdk/extras/qaas/__init__.py +0 -13
  79. qilisdk/extras/qaas/models.py +0 -132
  80. qilisdk/extras/qaas/qaas_backend.py +0 -255
  81. qilisdk/extras/qaas/qaas_digital_result.py +0 -20
  82. qilisdk/extras/qaas/qaas_time_evolution_result.py +0 -20
  83. qilisdk/extras/qaas/qaas_vqe_result.py +0 -20
  84. qilisdk-0.1.4.dist-info/RECORD +0 -51
  85. {qilisdk-0.1.4.dist-info → qilisdk-0.1.6.dist-info}/WHEEL +0 -0
  86. {qilisdk-0.1.4.dist-info → qilisdk-0.1.6.dist-info}/licenses/LICENCE +0 -0
qilisdk/core/model.py ADDED
@@ -0,0 +1,1034 @@
1
+ # Copyright 2025 Qilimanjaro Quantum Tech
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from __future__ import annotations
15
+
16
+ # import numpy as np
17
+ import copy
18
+ from enum import Enum
19
+ from typing import TYPE_CHECKING, Literal, Mapping, Type
20
+
21
+ # import cupy as np
22
+ import numpy as np
23
+ from loguru import logger
24
+
25
+ from qilisdk.yaml import yaml
26
+
27
+ from .variables import (
28
+ GEQ,
29
+ LEQ,
30
+ BaseVariable,
31
+ Bitwise,
32
+ ComparisonOperation,
33
+ ComparisonTerm,
34
+ Domain,
35
+ Number,
36
+ Operation,
37
+ RealNumber,
38
+ Term,
39
+ Variable,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from ruamel.yaml.nodes import ScalarNode
44
+ from ruamel.yaml.representer import RoundTripRepresenter
45
+
46
+ from qilisdk.analog.hamiltonian import Hamiltonian
47
+
48
+
49
+ class SlackCounter:
50
+ """A singleton class to generate a slack counter id that increments continuously within the user's active session."""
51
+
52
+ _instance: SlackCounter | None = None
53
+ _count: int = 0
54
+
55
+ def __new__(cls: Type[SlackCounter]) -> SlackCounter: # noqa: PYI034
56
+ if cls._instance is None:
57
+ cls._instance = super().__new__(cls)
58
+ return cls._instance
59
+
60
+ def next(self) -> int:
61
+ """Return the next counter value and increment the counter."""
62
+ value = self._count
63
+ self._count += 1
64
+ return value
65
+
66
+ def reset_counter(self) -> None:
67
+ self._count = 0
68
+
69
+
70
+ @yaml.register_class
71
+ class ObjectiveSense(str, Enum):
72
+ """An Enumeration of the Objective sense options."""
73
+
74
+ MINIMIZE = "minimize"
75
+ MAXIMIZE = "maximize"
76
+
77
+ @classmethod
78
+ def to_yaml(cls, representer: RoundTripRepresenter, node: ObjectiveSense) -> ScalarNode:
79
+ """
80
+ Method to be called automatically during YAML serialization.
81
+
82
+ Returns:
83
+ ScalarNode: The YAML scalar node representing the ObjectiveSense.
84
+ """
85
+ return representer.represent_scalar("!ObjectiveSense", f"{node.value}")
86
+
87
+ @classmethod
88
+ def from_yaml(cls, _, node: ScalarNode) -> ObjectiveSense:
89
+ """
90
+ Method to be called automatically during YAML deserialization.
91
+
92
+ Returns:
93
+ ObjectiveSense: The ObjectiveSense instance created from the YAML node value.
94
+ """
95
+ return cls(node.value)
96
+
97
+
98
+ @yaml.register_class
99
+ class Constraint:
100
+ """
101
+ Represent a symbolic constraint inside a ``Model``.
102
+
103
+ Example:
104
+ .. code-block:: python
105
+
106
+ from qilisdk.core.model import Constraint
107
+ from qilisdk.core.variables import BinaryVariable, LEQ
108
+
109
+ x = BinaryVariable("x")
110
+ constraint = Constraint("limit", LEQ(x, 1))
111
+ """
112
+
113
+ def __init__(self, label: str, term: ComparisonTerm) -> None:
114
+ """
115
+ Build a constraint defined by a comparison term such as ``x + y <= 2``.
116
+
117
+ Args:
118
+ label (str): The constraint's label.
119
+ term (ComparisonTerm): The comparison term that defines the constraint.
120
+
121
+ Raises:
122
+ ValueError: if the term provided is not a ConstraintTerm.
123
+ """
124
+ self._label = label
125
+ if not isinstance(term, ComparisonTerm):
126
+ raise ValueError(f"the parameter term is expecting a {ComparisonTerm} but received {term.__class__}")
127
+
128
+ self._term = term
129
+
130
+ @property
131
+ def label(self) -> str:
132
+ """
133
+ Returns:
134
+ str: The label of the constraint object.
135
+ """
136
+ return self._label
137
+
138
+ @property
139
+ def term(self) -> ComparisonTerm:
140
+ """
141
+ Returns:
142
+ ComparisonTerm: The comparison term of the constraint object.
143
+ """
144
+ return self._term
145
+
146
+ def variables(self) -> list[BaseVariable]:
147
+ """
148
+ Returns the list of variables in the constraint term.
149
+
150
+ :rtype: list[BaseVariable]
151
+ Returns:
152
+ list[BaseVariable]: the list of variables in the constraint term.
153
+ """
154
+ return self._term.variables()
155
+
156
+ @property
157
+ def lhs(self) -> Term:
158
+ """
159
+ Returns:
160
+ Term: The left hand side of the constraint term.
161
+ """
162
+ return self.term.lhs
163
+
164
+ @property
165
+ def rhs(self) -> Term:
166
+ """
167
+ Returns:
168
+ Term: The right hand side of the constraint term.
169
+ """
170
+ return self.term.rhs
171
+
172
+ @property
173
+ def degree(self) -> int:
174
+ """
175
+ Returns:
176
+ int: The degree of the constraint term.
177
+ """
178
+ return max(self.lhs.degree, self.rhs.degree)
179
+
180
+ def __copy__(self) -> Constraint:
181
+ return Constraint(label=self.label, term=copy.copy(self.term))
182
+
183
+ def __repr__(self) -> str:
184
+ return f"{self.label}: {self.term}"
185
+
186
+ def __str__(self) -> str:
187
+ return f"{self.label}: {self.term}"
188
+
189
+
190
+ @yaml.register_class
191
+ class Objective:
192
+ """
193
+ Represent the scalar objective function optimized by a ``Model``.
194
+
195
+ Example:
196
+ .. code-block:: python
197
+
198
+ from qilisdk.core.model import Objective, ObjectiveSense
199
+ from qilisdk.core.variables import BinaryVariable
200
+
201
+ x = BinaryVariable("x")
202
+ obj = Objective("profit", 3 * x, sense=ObjectiveSense.MAXIMIZE)
203
+ """
204
+
205
+ def __init__(self, label: str, term: BaseVariable | Term, sense: ObjectiveSense = ObjectiveSense.MINIMIZE) -> None:
206
+ """
207
+ Build a new objective function.
208
+
209
+ Args:
210
+ label (str): Objective label.
211
+ term (BaseVariable | Term): Expression to minimize or maximize.
212
+ sense (ObjectiveSense, optional): Optimization sense. Defaults to ``ObjectiveSense.MINIMIZE``.
213
+
214
+ Raises:
215
+ ValueError: if the term provided is not a Term Object.
216
+ ValueError: if the optimization sense provided is not one that is defined by the ObjectiveSense Enum.
217
+ """
218
+ if isinstance(term, Variable):
219
+ term = Term(elements=[term], operation=Operation.ADD)
220
+ if not isinstance(term, Term):
221
+ raise ValueError(f"the parameter term is expecting a {Term} but received {term.__class__}")
222
+ if not isinstance(sense, ObjectiveSense):
223
+ raise ValueError(f"the objective sense is expecting a {ObjectiveSense} but received {sense.__class__}")
224
+ self._term = term
225
+ self._label = label
226
+ self._sense = sense
227
+
228
+ @property
229
+ def label(self) -> str:
230
+ """
231
+ Returns:
232
+ str: the label of the objective.
233
+ """
234
+ return self._label
235
+
236
+ @property
237
+ def term(self) -> Term:
238
+ """
239
+ Returns:
240
+ Term: the objective term.
241
+ """
242
+ return self._term
243
+
244
+ @property
245
+ def sense(self) -> ObjectiveSense:
246
+ """
247
+ Returns:
248
+ ObjectiveSense: the objective optimization sense.
249
+ """
250
+ return self._sense
251
+
252
+ def variables(self) -> list[BaseVariable]:
253
+ """Gathers a list of all the variables in the objective term.
254
+
255
+ Returns:
256
+ list[BaseVariable]: the list of variables in the objective term.
257
+ """
258
+ return self._term.variables()
259
+
260
+ def __repr__(self) -> str:
261
+ return f"{self.label}: {self.term}"
262
+
263
+ def __str__(self) -> str:
264
+ return f"{self.label}: {self.term}"
265
+
266
+ def __copy__(self) -> Objective:
267
+ return Objective(label=self.label, term=copy.copy(self.term), sense=self.sense)
268
+
269
+
270
+ @yaml.register_class
271
+ class Model:
272
+ """
273
+ Aggregate an objective and constraints into an optimization problem.
274
+
275
+ Example:
276
+ .. code-block:: python
277
+
278
+ from qilisdk.core import BinaryVariable, LEQ, Model
279
+
280
+ num_items = 4
281
+ values = [1, 3, 5, 2]
282
+ weights = [3, 2, 4, 5]
283
+ max_weight = 6
284
+ bin_vars = [BinaryVariable(f"b{i}") for i in range(num_items)]
285
+ model = Model("Knapsack")
286
+ objective = sum(values[i] * bin_vars[i] for i in range(num_items))
287
+ model.set_objective(objective)
288
+ constraint = LEQ(sum(weights[i] * bin_vars[i] for i in range(num_items)), max_weight)
289
+ model.add_constraint("maximum weight", constraint)
290
+
291
+ print(model)
292
+ """
293
+
294
+ def __init__(self, label: str) -> None:
295
+ """
296
+ Args:
297
+ label (str): Model label.
298
+ """
299
+ self._constraints: dict[str, Constraint] = {}
300
+ self._encoding_constraints: dict[str, Constraint] = {}
301
+ self._lagrange_multipliers: dict[str, float] = {}
302
+ self._objective = Objective("objective", Term([0], Operation.ADD))
303
+ self._label = label
304
+
305
+ @property
306
+ def lagrange_multipliers(self) -> dict[str, float]:
307
+ return self._lagrange_multipliers
308
+
309
+ def set_lagrange_multiplier(self, constraint_label: str, lagrange_multiplier: float) -> None:
310
+ """Sets the lagrange multiplier value for a given constraint.
311
+
312
+ Args:
313
+ constraint_label (str): the constraint to which the lagrange multiplier value corresponds.
314
+ lagrange_multiplier (float): the lagrange multiplier value.
315
+
316
+ Raises:
317
+ ValueError: if the constraint provided is not in the model.
318
+ """
319
+ if constraint_label not in self._lagrange_multipliers:
320
+ raise ValueError(f'constraint "{constraint_label}" not in model.')
321
+ self.lagrange_multipliers[constraint_label] = lagrange_multiplier
322
+
323
+ @property
324
+ def label(self) -> str:
325
+ """
326
+ Returns:
327
+ str: The model label.
328
+ """
329
+ return self._label
330
+
331
+ @property
332
+ def constraints(self) -> list[Constraint]:
333
+ """
334
+ Returns:
335
+ list[Constraint]: a list of all the constraints in the model.
336
+ """
337
+ return list(self._constraints.values())
338
+
339
+ @property
340
+ def encoding_constraints(self) -> list[Constraint]:
341
+ """
342
+ Returns:
343
+ list[Constraint]: a list of all variable encoding constraints in the model.
344
+ """
345
+ return list(self._encoding_constraints.values())
346
+
347
+ @property
348
+ def objective(self) -> Objective:
349
+ """
350
+ Returns:
351
+ Objective: The objective of the model.
352
+ """
353
+ return self._objective
354
+
355
+ def variables(self) -> list[BaseVariable]:
356
+ """
357
+ Returns:
358
+ list[BaseVariable]: a list of variables that are used in the model whether that is in the constraints
359
+ or the objective.
360
+ """
361
+ var = set()
362
+
363
+ for c in self.constraints:
364
+ var.update(c.variables())
365
+
366
+ var.update(self.objective.variables())
367
+
368
+ return sorted(var, key=lambda x: x.label)
369
+
370
+ def _generate_encoding_constraints(
371
+ self,
372
+ lagrange_multiplier: float = 100,
373
+ ) -> None:
374
+ for var in self.variables():
375
+ if not isinstance(var, Variable) or var.domain in {Domain.BINARY, Domain.SPIN}:
376
+ continue
377
+ ub_encoding_name = f"{var}_upper_bound_constraint"
378
+ lb_encoding_name = f"{var}_lower_bound_constraint"
379
+ if ub_encoding_name not in self._encoding_constraints:
380
+ self._encoding_constraints[ub_encoding_name] = Constraint(
381
+ label=ub_encoding_name, term=LEQ(var, var.upper_bound)
382
+ )
383
+ self._lagrange_multipliers[ub_encoding_name] = lagrange_multiplier
384
+ if lb_encoding_name not in self._encoding_constraints:
385
+ self._encoding_constraints[lb_encoding_name] = Constraint(
386
+ label=lb_encoding_name, term=GEQ(var, var.lower_bound)
387
+ )
388
+ self._lagrange_multipliers[lb_encoding_name] = lagrange_multiplier
389
+
390
+ def __str__(self) -> str:
391
+ output = f"Model name: {self.label} \n"
392
+ if self.objective is not None:
393
+ output += (
394
+ f"objective ({self.objective.label}):"
395
+ + f" \n\t {self.objective.sense.value} : \n\t {self.objective.term} \n\n"
396
+ )
397
+ if len(self.constraints) > 0:
398
+ output += "subject to the constraint/s: \n"
399
+ for c in self.constraints:
400
+ output += f"\t {c} \n"
401
+ output += "\n"
402
+
403
+ if len(self.encoding_constraints) > 0:
404
+ output += "subject to the encoding constraint/s: \n"
405
+ for c in self.encoding_constraints:
406
+ output += f"\t {c} \n"
407
+ output += "\n"
408
+
409
+ if len(self.lagrange_multipliers) > 0:
410
+ output += "With Lagrange Multiplier/s: \n"
411
+ for key, value in self.lagrange_multipliers.items():
412
+ output += f"\t {key} : {value} \n"
413
+ return output
414
+
415
+ def __repr__(self) -> str:
416
+ return self.label
417
+
418
+ def __copy__(self) -> Model:
419
+ out = Model(label=self.label)
420
+ obj = copy.copy(self.objective)
421
+ out.set_objective(term=obj.term, label=obj.label, sense=obj.sense)
422
+ for c in self.constraints:
423
+ out.add_constraint(label=c.label, term=copy.copy(c.term))
424
+ return out
425
+
426
+ def add_constraint(
427
+ self,
428
+ label: str,
429
+ term: ComparisonTerm,
430
+ lagrange_multiplier: float = 100,
431
+ ) -> None:
432
+ """Add a constraint to the model.
433
+
434
+ Args:
435
+ label (str): constraint label.
436
+ term (ComparisonTerm): The constraint's comparison term.
437
+
438
+ Raises:
439
+ ValueError: if the constraint label is already used in the model.
440
+ """
441
+ if label in self._constraints:
442
+ raise ValueError((f'Constraint "{label}" already exists:\n \t\t{self._constraints[label]}'))
443
+ c = Constraint(label=label, term=copy.copy(term))
444
+ self._constraints[label] = c
445
+ self._lagrange_multipliers[label] = lagrange_multiplier
446
+ self._generate_encoding_constraints(lagrange_multiplier=lagrange_multiplier)
447
+
448
+ def set_objective(self, term: Term, label: str = "obj", sense: ObjectiveSense = ObjectiveSense.MINIMIZE) -> None:
449
+ """Sets the model's objective.
450
+
451
+ Args:
452
+ term (Term): the objective term.
453
+ label (str, optional): the objective's label. Defaults to "obj".
454
+ sense (ObjectiveSense, optional): The optimization sense of the model's objective.
455
+ Defaults to ObjectiveSense.MINIMIZE.
456
+ """
457
+ self._objective = Objective(label=label, term=copy.copy(term), sense=sense)
458
+ self._generate_encoding_constraints()
459
+
460
+ def evaluate(self, sample: Mapping[BaseVariable, RealNumber | list[int]]) -> dict[str, Number]:
461
+ """Evaluates the objective and the constraints of the model given a set of values for the variables.
462
+
463
+ Args:
464
+ sample (Mapping[BaseVariable, Number | list[int]]): The dictionary maps the variable to the value to be
465
+ used during the evaluation. In case the variable is
466
+ continuous (Not Binary or Spin) then the value could
467
+ either be a number or a list of binary bits that
468
+ correspond to the encoding of the variable.
469
+ Note: All the model's variables must be provided for
470
+ the model to be evaluated.
471
+
472
+ Returns:
473
+ dict[str, float]: a dictionary that maps the name of the objective/constraint to it's evaluated value.
474
+ Note: For constraints, the value is equal to lagrange multiplier of that constraint if
475
+ the constraint is not satisfied or 0 otherwise.
476
+ """
477
+ results = {}
478
+
479
+ results[self.objective.label] = self.objective.term.evaluate(sample)
480
+ results[self.objective.label] *= -1 if self.objective.sense is ObjectiveSense.MAXIMIZE else 1
481
+
482
+ for c in self.constraints:
483
+ results[c.label] = float(not c.term.evaluate(sample)) * self.lagrange_multipliers[c.label]
484
+ return results
485
+
486
+ def to_qubo(
487
+ self,
488
+ lagrange_multiplier_dict: dict[str, float] | None = None,
489
+ penalization: Literal["unbalanced", "slack"] = "slack",
490
+ parameters: list[float] | None = None,
491
+ ) -> QUBO:
492
+ """Export the model to a qubo model.
493
+ Args:
494
+ lagrange_multiplier_dict (dict[str, float] | None, optional): A dictionary with lagrange multiplier values to scale the model's constraints. Defaults to None.
495
+ penalization (Literal[&quot;unbalanced&quot;, &quot;slack&quot;], optional): the penalization used to handel inequality constraints. Defaults to "slack".
496
+ parameters (list[float] | None, optional): the parameters used for the unbalanced penalization method. Defaults to None.
497
+
498
+ Note:
499
+ this exportation only works if the model doesn't violate the QUBO format.
500
+ Automatic constraint and objective linearization will be added in the future.
501
+ Returns:
502
+ QUBO: A QUBO model that is generate from the model object.
503
+ """
504
+ if lagrange_multiplier_dict is None:
505
+ lagrange_multiplier_dict = {}
506
+ for lm in self.lagrange_multipliers:
507
+ if lm not in lagrange_multiplier_dict:
508
+ lagrange_multiplier_dict[lm] = self.lagrange_multipliers[lm]
509
+ return QUBO.from_model(self, lagrange_multiplier_dict, penalization, parameters)
510
+
511
+
512
+ @yaml.register_class
513
+ class QUBO(Model):
514
+ """
515
+ Specialized ``Model`` constrained to Quadratic Unconstrained Binary Optimization form.
516
+
517
+ Example:
518
+ .. code-block:: python
519
+
520
+ from qilisdk.core.model import QUBO
521
+ from qilisdk.core.variables import BinaryVariable
522
+
523
+ x0, x1 = BinaryVariable("x0"), BinaryVariable("x1")
524
+ qubo = QUBO("Example")
525
+ qubo.set_objective((x0 + x1) ** 2)
526
+ """
527
+
528
+ def __init__(self, label: str) -> None:
529
+ """
530
+ Args:
531
+ label (str): QUBO model label.
532
+ """
533
+ super().__init__(label)
534
+ self.continuous_vars: dict[str, Variable] = {}
535
+ self.__qubo_objective: Objective | None = None
536
+
537
+ @property
538
+ def qubo_objective(self) -> Objective | None:
539
+ """
540
+ Returns:
541
+ Objective | None: The QUBO objective (factoring in the constraints and objective of the model). If the objective and constraints are not defined in the model, this property returns None.
542
+ """
543
+ self.__qubo_objective = None
544
+ if self.objective is not None:
545
+ self._build_qubo_objective(self.objective.term, self.objective.label, self.objective.sense)
546
+ for constraint in self.constraints:
547
+ if constraint.label in self.lagrange_multipliers:
548
+ self._build_qubo_objective(
549
+ constraint.term.lhs * self.lagrange_multipliers[constraint.label]
550
+ - constraint.term.rhs * self.lagrange_multipliers[constraint.label]
551
+ )
552
+ else:
553
+ self._build_qubo_objective(
554
+ constraint.term.lhs - constraint.term.rhs
555
+ ) # I don't think this line can be reached.
556
+ return self.__qubo_objective
557
+
558
+ def __repr__(self) -> str:
559
+ return self.label
560
+
561
+ def _parse_term(self, term: Term) -> tuple[Number, list[tuple[Number, BaseVariable]]]:
562
+ """parses a Term object into a list of variables and coefficients.
563
+
564
+ Args:
565
+ term (Term): The term to be parsed.
566
+
567
+ Raises:
568
+ ValueError: if the degree of the term is higher than 1.
569
+
570
+ Returns:
571
+ tuple[Number, list[tuple[Number, BaseVariable]]]:
572
+ The first return value is the constant value in the term.
573
+ The second return value is a list of variables and their respective coefficients.
574
+ """
575
+ const = term.get_constant()
576
+ terms: list[tuple[Number, BaseVariable]] = []
577
+
578
+ if term.degree > 1:
579
+ raise ValueError(f'QUBO constraints only allow linear terms but received "{term}" of degree {term.degree}')
580
+
581
+ if term.operation is Operation.ADD:
582
+ for element in term:
583
+ if isinstance(element, Term): # I don't think this will ever be true for a QUBO model.
584
+ _, aux_terms = self._parse_term(element)
585
+ terms.extend(aux_terms)
586
+ elif element != Term.CONST:
587
+ terms.append((term[element], element))
588
+ if term.operation is Operation.MUL:
589
+ for element in term:
590
+ if not isinstance(element, Term) and element != Term.CONST:
591
+ terms.append((1, element))
592
+ return const, terms
593
+
594
+ def _check_valid_constraint(self, label: str, term: Term, operation: ComparisonOperation) -> int | None:
595
+ """Checks if a given constraint is valid. Assumes that the right hand side of the constraint is set to zero.
596
+
597
+ Args:
598
+ label (str): the label of the constraint.
599
+ term (Term): the left hand side of the constraint term.
600
+ operation (ComparisonOperation): the comparison operation between the left and right hand sides.
601
+
602
+ Raises:
603
+ ValueError: if the constraint is never feasible given the variable ranges.
604
+
605
+ Returns:
606
+ int | None: the upper bound of the continuous slack variable needed for this given constraint.
607
+ None in case the constraint is always feasible.
608
+ """
609
+ ub = np.iinfo(np.int64).max if operation in {ComparisonOperation.GEQ, ComparisonOperation.GT} else 0
610
+ lb = np.iinfo(np.int64).min if operation in {ComparisonOperation.LEQ, ComparisonOperation.LT} else 0
611
+ _const, terms = self._parse_term(term)
612
+
613
+ def to_real(num: Number) -> RealNumber:
614
+ if isinstance(num, RealNumber):
615
+ return num
616
+ if isinstance(num, complex) and num.imag == 0:
617
+ return num.real
618
+ raise ValueError("Complex values encountered in the constraint.")
619
+
620
+ const = to_real(_const)
621
+ term_upper_limit: RealNumber = sum(to_real(coeff) for coeff, _ in terms if to_real(coeff) > 0)
622
+ term_lower_limit: RealNumber = sum(to_real(coeff) for coeff, _ in terms if to_real(coeff) < 0)
623
+
624
+ if operation == ComparisonOperation.GT and term_upper_limit + const <= 0:
625
+ raise ValueError(f"Constraint {label} is unsatisfiable.")
626
+ if operation == ComparisonOperation.LT and term_lower_limit + const >= 0:
627
+ raise ValueError(f"Constraint {label} is unsatisfiable.")
628
+
629
+ upper_cut = min(term_upper_limit, ub - const)
630
+ lower_cut = max(term_lower_limit, lb - const)
631
+
632
+ if term_upper_limit <= upper_cut and term_lower_limit >= lower_cut:
633
+ logger.warning(
634
+ f'constraint "{label}" was not added to model "{self.label}" because it is always feasible.',
635
+ )
636
+ return None
637
+
638
+ ub_slack = int(upper_cut - lower_cut)
639
+
640
+ if upper_cut < lower_cut:
641
+ raise ValueError(f"Constraint {label} is unsatisfiable.")
642
+
643
+ return ub_slack
644
+
645
+ def _transform_constraint(
646
+ self,
647
+ label: str,
648
+ term: ComparisonTerm,
649
+ penalization: Literal["unbalanced", "slack"] = "slack",
650
+ parameters: list[float] | None = None,
651
+ ) -> Term | None:
652
+ """Transforms a constraint into QUBO format.
653
+
654
+ Args:
655
+ label (str): the constraint's label.
656
+ term (ComparisonTerm): the constraint term.
657
+ penalization (Literal[&quot;unbalanced&quot;, &quot;slack&quot;], optional): The penalization used to
658
+ handel inequality constraints. Defaults to "slack".
659
+ parameters (list[float] | None, optional): the parameters used for the unbalanced penalization method.
660
+ Defaults to None.
661
+
662
+ Raises:
663
+ ValueError: if a penalization method is provided that is not (&quot;unbalanced&quot;, &quot;slack&quot;)
664
+ ValueError: if unbalanced penalization method is used and not enough parameters are provided.
665
+
666
+ Returns:
667
+ Term | None: A transformed term that is in QUBO format.
668
+ None if the constraint is always feasible.
669
+ """
670
+
671
+ lower_penalization = penalization.lower()
672
+
673
+ if lower_penalization not in {"unbalanced", "slack"}:
674
+ raise ValueError('Only penalization of type "unbalanced" or "slack" is supported.')
675
+
676
+ if parameters is None:
677
+ parameters = []
678
+
679
+ if term.operation is ComparisonOperation.EQ:
680
+ h = term.lhs - term.rhs
681
+ ub_slack = self._check_valid_constraint(label, h, term.operation)
682
+ if ub_slack is None:
683
+ return None
684
+ return h**2
685
+
686
+ if term.operation in {
687
+ ComparisonOperation.GEQ,
688
+ ComparisonOperation.GT,
689
+ }:
690
+ # assuming the operation is h >= 0 or h > 0
691
+ h = term.lhs - term.rhs
692
+ if lower_penalization == "unbalanced":
693
+ if len(parameters) < 2: # noqa: PLR2004
694
+ raise ValueError("using unbalanced penalization requires at least 2 parameters.")
695
+ return -parameters[0] * h + parameters[1] * (h**2)
696
+
697
+ if lower_penalization == "slack":
698
+ ub_slack = self._check_valid_constraint(label, h, term.operation)
699
+
700
+ if ub_slack is None:
701
+ return None
702
+ if ub_slack == 0:
703
+ return h**2
704
+
705
+ slack = Variable(
706
+ f"{label}_slack", domain=Domain.POSITIVE_INTEGER, bounds=(0, ub_slack), encoding=Bitwise
707
+ )
708
+ slack_terms = slack.to_binary()
709
+ out = h + slack_terms
710
+ return (out) ** 2
711
+
712
+ if term.operation in {
713
+ ComparisonOperation.LEQ,
714
+ ComparisonOperation.LT,
715
+ }:
716
+ if lower_penalization == "unbalanced":
717
+ # assuming the operation is -> 0 < h or 0 <= h
718
+ h = term.rhs - term.lhs
719
+ if len(parameters) < 2: # noqa: PLR2004
720
+ raise ValueError("using unbalanced penalization requires at least 2 parameters.")
721
+ return -parameters[0] * h + parameters[1] * (h**2)
722
+ if lower_penalization == "slack":
723
+ # assuming the operation is h <= 0 or h < 0
724
+ h = term.lhs - term.rhs
725
+ ub_slack = self._check_valid_constraint(label, h, term.operation)
726
+
727
+ if ub_slack is None:
728
+ return None
729
+ if ub_slack == 0:
730
+ return h**2
731
+
732
+ slack = Variable(
733
+ f"{label}_slack", domain=Domain.POSITIVE_INTEGER, bounds=(0, ub_slack), encoding=Bitwise
734
+ )
735
+
736
+ slack_terms = slack.to_binary()
737
+ out = h + slack_terms
738
+ return (out) ** 2
739
+ return None
740
+
741
+ def add_constraint(
742
+ self,
743
+ label: str,
744
+ term: ComparisonTerm,
745
+ lagrange_multiplier: float = 100,
746
+ penalization: Literal["unbalanced", "slack"] = "slack",
747
+ parameters: list[float] | None = None,
748
+ transform_to_qubo: bool = True,
749
+ ) -> None:
750
+ """Adds a constraint to the QUBO model.
751
+
752
+ Args:
753
+ label (str): the constraint label.
754
+ term (ComparisonTerm): the constraint's comparison term.
755
+ lagrange_multiplier (float, optional): the lagrange multiplier used to scale this constraint.
756
+ Defaults to 100.
757
+ penalization (Literal[&quot;unbalanced&quot;, &quot;slack&quot;], optional): the penalization used to
758
+ handel inequality constraints. Defaults to "slack".
759
+ parameters (list[float] | None, optional): the parameters used for the unbalanced penalization method.
760
+ Defaults to None.
761
+ transform_to_qubo (bool, optional): Automatically transform a given constraint to QUBO format.
762
+ Defaults to True.
763
+
764
+ Raises:
765
+ ValueError: if constraint label already exists in the model.
766
+ ValueError: if a penalization method is provided that is not (&quot;unbalanced&quot;, &quot;slack&quot;)
767
+ ValueError: if unbalanced penalization method is used and not enough parameters are provided.
768
+ ValueError: if the degree of the provided term is larger than 2.
769
+ ValueError: if the constraint term contains variables that are not from Positive Integers or Binary domains.
770
+ ValueError: if the constraint term contains variable that do not have 0 as their lower bound.
771
+ """
772
+ if label in self._constraints:
773
+ raise ValueError((f'Constraint "{label}" already exists:\n \t\t{self._constraints[label]}'))
774
+
775
+ lower_penalization = penalization.lower()
776
+
777
+ if lower_penalization not in {"unbalanced", "slack"}:
778
+ raise ValueError(
779
+ 'Only penalization of type "unbalanced" or "slack" is supported for inequality constraints.'
780
+ )
781
+
782
+ if parameters is None:
783
+ parameters = [1, 1] if lower_penalization == "unbalanced" else []
784
+
785
+ if term.operation in {ComparisonOperation.GEQ, ComparisonOperation.GT}:
786
+ c = ComparisonTerm(lhs=(term.lhs - term.rhs), rhs=0, operation=term.operation)
787
+ elif term.operation in {ComparisonOperation.LEQ, ComparisonOperation.LT}:
788
+ c = ComparisonTerm(lhs=0, rhs=(term.rhs - term.lhs), operation=term.operation)
789
+ else:
790
+ c = copy.copy(term)
791
+
792
+ if c.degree > 2: # noqa: PLR2004
793
+ raise ValueError(
794
+ f"QUBO models can not contain terms of order 2 or higher but received terms with degree {c.degree}."
795
+ )
796
+
797
+ self._check_variables(c, lagrange_multiplier=lagrange_multiplier)
798
+
799
+ if transform_to_qubo:
800
+ c = c.to_binary()
801
+ transformed_c = self._transform_constraint(label, c, penalization=penalization, parameters=parameters)
802
+ if transformed_c is None:
803
+ return
804
+ if lower_penalization == "unbalanced" and lagrange_multiplier != 1:
805
+ self.lagrange_multipliers[label] = 1
806
+ logger.warning(
807
+ "add_constraint() in QUBO model:"
808
+ + f' The Lagrange Multiplier for the constraint "{label}" in the QUBO model ({self.label})'
809
+ + " has been set to 1 because the constraint uses unbalanced"
810
+ + " penalization method."
811
+ + ' To customize the penalization coefficient, please use the "parameters" field.',
812
+ )
813
+ else:
814
+ self.lagrange_multipliers[label] = lagrange_multiplier
815
+ self._constraints[label] = Constraint(label, term=ComparisonTerm(transformed_c, 0, ComparisonOperation.EQ))
816
+
817
+ else:
818
+ self.lagrange_multipliers[label] = lagrange_multiplier
819
+ self._constraints[label] = Constraint(label, term=c)
820
+
821
+ def set_objective(self, term: Term, label: str = "obj", sense: ObjectiveSense = ObjectiveSense.MINIMIZE) -> None:
822
+ """Set the QUBO objective.
823
+
824
+ Args:
825
+ term (Term): The objective's term.
826
+ label (str, optional): the objective's label. Defaults to "obj".
827
+ sense (ObjectiveSense, optional): The optimization sense of the model's objective.
828
+ Defaults to ObjectiveSense.MINIMIZE.
829
+
830
+ """
831
+
832
+ self._check_variables(term)
833
+
834
+ term = term.to_binary()
835
+ self._objective = Objective(label=label, term=term, sense=sense)
836
+
837
+ def _check_variables(self, term: Term | ComparisonTerm, lagrange_multiplier: RealNumber = 100) -> None:
838
+ """checks if the variables in the provided term are valid to be used in a QUBO model. Moreover, we add all the
839
+ encoding constraint for supported continuous variables.
840
+
841
+ Args:
842
+ term (Term): the term to be checked.
843
+
844
+ Raises:
845
+ ValueError: if the constraint term contains variables that are not from Positive Integers or Binary domains.
846
+ ValueError: if the constraint term contains variable that do not have 0 as their lower bound.
847
+ """
848
+ for v in term.variables():
849
+ if v.domain not in {Domain.POSITIVE_INTEGER, Domain.BINARY}:
850
+ raise ValueError(
851
+ "QUBO models are not supported for variables that are not in the positive integers or binary domains."
852
+ )
853
+ if v.lower_bound != 0:
854
+ raise ValueError(
855
+ f"All variables must have a lower bound of 0. But variable {v} has a lower bound of {v.lower_bound}"
856
+ )
857
+ if isinstance(v, Variable) and v.domain is Domain.POSITIVE_INTEGER and v.label not in self.continuous_vars:
858
+ self.continuous_vars[v.label] = v
859
+ encoding_constraint = v.encoding_constraint()
860
+ if encoding_constraint is not None:
861
+ enc_label = f"{v.label}_encoding_constraint"
862
+ self.add_constraint(
863
+ label=enc_label, term=encoding_constraint, lagrange_multiplier=lagrange_multiplier
864
+ )
865
+
866
+ def _build_qubo_objective(
867
+ self, term: Term, label: str | None = None, sense: ObjectiveSense = ObjectiveSense.MINIMIZE
868
+ ) -> None:
869
+ """updates the internal qubo objective term.
870
+
871
+ Args:
872
+ term (Term): A term to be added to the qubo objective.
873
+ label (str | None, optional): the label of the objective (if None then the current label is maintained).
874
+ Defaults to None.
875
+ sense (ObjectiveSense, optional): The optimization sense of the model's objective.
876
+ Defaults to ObjectiveSense.MINIMIZE.
877
+ """
878
+ term = copy.copy(term.to_binary())
879
+ if self.__qubo_objective is None:
880
+ self.__qubo_objective = Objective(
881
+ label=label if label is not None else "obj",
882
+ term=-term if sense == ObjectiveSense.MAXIMIZE else term,
883
+ sense=ObjectiveSense.MINIMIZE,
884
+ )
885
+ else:
886
+ self.__qubo_objective = Objective(
887
+ label=label if label is not None else self.__qubo_objective.label,
888
+ term=(
889
+ copy.copy(self.__qubo_objective.term) - term
890
+ if sense == ObjectiveSense.MAXIMIZE
891
+ else copy.copy(self.__qubo_objective.term) + term
892
+ ),
893
+ sense=ObjectiveSense.MINIMIZE,
894
+ )
895
+
896
+ def evaluate(self, sample: Mapping[BaseVariable, RealNumber | list[int]]) -> dict[str, Number]:
897
+ """Evaluates the objective and the constraints of the model given a set of values for the variables.
898
+
899
+ Args:
900
+ sample (Mapping[BaseVariable, RealNumber | list[int]]): The dictionary maps the variable to the value to be
901
+ used during the evaluation. In case the variable is
902
+ continuous (Not Binary or Spin) then the value could
903
+ either be a number or a list of binary bits that
904
+ correspond to the encoding of the variable.
905
+ Note: All the model's variables must be provided for
906
+ the model to be evaluated.
907
+
908
+ Returns:
909
+ dict[str, float]: a dictionary that maps the name of the objective/constraint to it's evaluated value.
910
+ Note: For constraints, the value is equal to the value of the evaluated constraint term
911
+ multiplied by the lagrange multiplier of that constraint.
912
+ """
913
+ results = {}
914
+
915
+ results[self.objective.label] = self.objective.term.evaluate(sample)
916
+ results[self.objective.label] *= -1 if self.objective.sense is ObjectiveSense.MAXIMIZE else 1
917
+
918
+ for c in self.constraints:
919
+ results[c.label] = c.term.lhs.evaluate(sample) - c.term.rhs.evaluate(sample)
920
+ results[c.label] *= self.lagrange_multipliers[c.label]
921
+ return results
922
+
923
+ @classmethod
924
+ def from_model(
925
+ cls,
926
+ model: Model,
927
+ lagrange_multiplier_dict: dict[str, float] | None = None,
928
+ penalization: Literal["unbalanced", "slack"] = "slack",
929
+ parameters: list[float] | None = None,
930
+ ) -> QUBO:
931
+ """A class method that constructs a QUBO model from a regular model if possible.
932
+
933
+ Args:
934
+ model (Model): the model to be used to construct the QUBO model.
935
+ lagrange_multiplier_dict (dict[str, float] | None, optional): A dictionary with lagrange multiplier values
936
+ to scale the model's constraints. Defaults to None.
937
+ penalization (Literal[&quot;unbalanced&quot;, &quot;slack&quot;], optional): the penalization used to
938
+ handel inequality constraints. Defaults to "slack".
939
+ parameters (list[float] | None, optional): the parameters used for the unbalanced penalization method.
940
+ Defaults to None.
941
+ Returns:
942
+ QUBO: _description_
943
+ """
944
+ instance = QUBO(label="QUBO_" + model.label)
945
+ instance.set_objective(term=model.objective.term, label=model.objective.label, sense=model.objective.sense)
946
+ for constraint in model.constraints:
947
+ if lagrange_multiplier_dict is not None and constraint.label in lagrange_multiplier_dict:
948
+ lagrange_multiplier = lagrange_multiplier_dict[constraint.label]
949
+
950
+ else:
951
+ lagrange_multiplier = 100
952
+
953
+ instance.add_constraint(
954
+ label=constraint.label,
955
+ term=constraint.term,
956
+ lagrange_multiplier=lagrange_multiplier,
957
+ penalization=penalization,
958
+ parameters=parameters,
959
+ )
960
+ return instance
961
+
962
+ def to_hamiltonian(self) -> Hamiltonian:
963
+ """Construct an ising hamiltonian from the current QUBO model.
964
+
965
+ Raises:
966
+ ValueError: if the QUBO model is empty (doesn't have an objective nor constraints.)
967
+ ValueError: if the QUBO model uses operations that are not addition or multiplications.
968
+
969
+ Returns:
970
+ Hamiltonian: An ising hamiltonian that represents the QUBO model.
971
+ """
972
+ from qilisdk.analog.hamiltonian import Hamiltonian, Z # noqa: PLC0415
973
+
974
+ spins: dict[BaseVariable, Hamiltonian] = {}
975
+ obj = self.qubo_objective
976
+
977
+ if obj is None:
978
+ raise ValueError("Can't transform empty QUBO model to a Hamiltonian.")
979
+
980
+ for i, v in enumerate(obj.variables()):
981
+ spins[v] = (1 - Z(i)) / 2
982
+
983
+ def _parse_term(term: Term) -> Hamiltonian:
984
+ ham = Hamiltonian()
985
+ terms = term.to_list()
986
+ operation = term.operation
987
+ default = 0.0 if operation is Operation.ADD else 1.0
988
+ aux_term: Number | Hamiltonian = copy.copy(default)
989
+ for t in terms:
990
+ aux: Number | Hamiltonian = copy.copy(default)
991
+ if isinstance(t, Term):
992
+ aux = _parse_term(t)
993
+ elif isinstance(t, Number):
994
+ aux = t
995
+ elif isinstance(t, BaseVariable):
996
+ aux = spins[t]
997
+
998
+ if operation is Operation.ADD:
999
+ aux_term += aux
1000
+ elif operation is Operation.MUL:
1001
+ aux_term *= aux
1002
+ else: # I don't think this can be reached.
1003
+ raise ValueError(f"operation {operation} is not supported")
1004
+ ham += aux_term
1005
+ return ham
1006
+
1007
+ ham = _parse_term(obj.term)
1008
+
1009
+ return ham
1010
+
1011
+ def to_qubo(
1012
+ self,
1013
+ lagrange_multiplier_dict: dict[str, float] | None = None,
1014
+ penalization: Literal["unbalanced", "slack"] = "slack",
1015
+ parameters: list[float] | None = None,
1016
+ ) -> QUBO:
1017
+ logger.warning(
1018
+ f"Running `to_qubo()` on the model {self.label} that is already in QUBO format.",
1019
+ )
1020
+ return copy.copy(self)
1021
+
1022
+ def __copy__(self) -> QUBO:
1023
+ out = QUBO(label=self.label)
1024
+ obj = copy.copy(self.objective)
1025
+ out.set_objective(term=obj.term, label=obj.label, sense=obj.sense)
1026
+ for c in self.constraints:
1027
+ # THIS DOESN'T COPY ANY PARAMETERS ATTACHED TO A CONSTRAINT
1028
+ out.add_constraint(
1029
+ label=c.label,
1030
+ term=copy.copy(c.term),
1031
+ lagrange_multiplier=self.lagrange_multipliers[c.label],
1032
+ transform_to_qubo=False,
1033
+ )
1034
+ return out