qemcmc 0.1.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.
- qemcmc/__init__.py +25 -0
- qemcmc/circuits.py +180 -0
- qemcmc/coarse_grain.py +28 -0
- qemcmc/main.py +78 -0
- qemcmc/model/__init__.py +4 -0
- qemcmc/model/energy_model.py +328 -0
- qemcmc/sampler/__init__.py +6 -0
- qemcmc/sampler/classical_mcmc.py +82 -0
- qemcmc/sampler/mcmc.py +162 -0
- qemcmc/sampler/qe_mcmc.py +150 -0
- qemcmc/spectralgap.py +162 -0
- qemcmc/utils/__init__.py +13 -0
- qemcmc/utils/helpers.py +308 -0
- qemcmc/utils/model_maker.py +62 -0
- qemcmc-0.1.0.dist-info/METADATA +169 -0
- qemcmc-0.1.0.dist-info/RECORD +20 -0
- qemcmc-0.1.0.dist-info/WHEEL +5 -0
- qemcmc-0.1.0.dist-info/entry_points.txt +2 -0
- qemcmc-0.1.0.dist-info/licenses/LICENSE +21 -0
- qemcmc-0.1.0.dist-info/top_level.txt +1 -0
qemcmc/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .sampler import MCMC, ClassicalMCMC, QeMCMC
|
|
2
|
+
from .model import EnergyModel
|
|
3
|
+
from .circuits import CircuitMaker
|
|
4
|
+
from .utils import MCMCState, MCMCChain, plot_chains, get_random_state, get_all_possible_states, ModelMaker
|
|
5
|
+
from .spectralgap import SpectralGap
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EnergyModel",
|
|
9
|
+
"MCMC",
|
|
10
|
+
"ClassicalMCMC",
|
|
11
|
+
"QeMCMC",
|
|
12
|
+
"MCMCState",
|
|
13
|
+
"MCMCChain",
|
|
14
|
+
"ModelMaker",
|
|
15
|
+
"plot_chains",
|
|
16
|
+
"get_random_state",
|
|
17
|
+
"get_all_possible_states",
|
|
18
|
+
"SpectralGap",
|
|
19
|
+
"CircuitMaker",
|
|
20
|
+
"CoarseGraining",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
print("Hello from QeMCMC!")
|
qemcmc/circuits.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import pennylane as qml
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CircuitMaker:
|
|
6
|
+
"""
|
|
7
|
+
Constructs and simulates quantum circuits used to generate QeMCMC proposals.
|
|
8
|
+
|
|
9
|
+
This class builds the Hamiltonian corresponding to a given energy model and
|
|
10
|
+
simulates its time evolution using PennyLane. Starting from a classical
|
|
11
|
+
bitstring configuration, the circuit performs Trotterised quantum evolution
|
|
12
|
+
and samples a new configuration from the resulting quantum state.
|
|
13
|
+
|
|
14
|
+
The generated sample serves as the proposal state in the quantum-enhanced
|
|
15
|
+
MCMC algorithm.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
model : EnergyModel
|
|
20
|
+
Energy model defining the problem Hamiltonian.
|
|
21
|
+
gamma : float
|
|
22
|
+
Strength of the transverse-field (mixer) term in the Hamiltonian.
|
|
23
|
+
time : int or tuple[int, int]
|
|
24
|
+
Total evolution time or range of evolution times used in the simulation.
|
|
25
|
+
delta_time : float, optional
|
|
26
|
+
Duration of each Trotter step used in the approximate time evolution.
|
|
27
|
+
Default is 0.8.
|
|
28
|
+
|
|
29
|
+
Notes
|
|
30
|
+
-----
|
|
31
|
+
The total Hamiltonian simulated by the circuit is
|
|
32
|
+
|
|
33
|
+
H = γ H_mixer + (1 - γ) α H_problem
|
|
34
|
+
|
|
35
|
+
where ``H_problem`` encodes the classical energy model and ``H_mixer``
|
|
36
|
+
corresponds to a transverse-field term. The evolution is approximated
|
|
37
|
+
using Trotterisation via ``qml.ApproxTimeEvolution``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, model, gamma, time, delta_time=0.8):
|
|
41
|
+
self.model = model
|
|
42
|
+
self.gamma = gamma
|
|
43
|
+
self.time = time
|
|
44
|
+
self.delta_time = delta_time
|
|
45
|
+
self.n_qubits = model.n
|
|
46
|
+
|
|
47
|
+
# Calculate number of Trotter steps
|
|
48
|
+
t_val = self.time[0] if isinstance(self.time, tuple) else self.time
|
|
49
|
+
self.num_trotter_steps = int(np.floor((t_val / self.delta_time)))
|
|
50
|
+
|
|
51
|
+
self.dev = qml.device("lightning.qubit", wires=self.n_qubits)
|
|
52
|
+
|
|
53
|
+
def get_problem_hamiltonian(self, couplings, sign=-1):
|
|
54
|
+
"""
|
|
55
|
+
Construct Problem Hamiltonian from symmetric coupling tensors.
|
|
56
|
+
"""
|
|
57
|
+
coeffs = []
|
|
58
|
+
obs = []
|
|
59
|
+
|
|
60
|
+
for coupling_tensor in couplings:
|
|
61
|
+
coupling_tensor = np.asarray(coupling_tensor)
|
|
62
|
+
order = coupling_tensor.ndim
|
|
63
|
+
if order == 0:
|
|
64
|
+
continue
|
|
65
|
+
spin_sign = (-1) ** order
|
|
66
|
+
non_zero_indices = np.transpose(np.nonzero(coupling_tensor))
|
|
67
|
+
for index_tuple in non_zero_indices:
|
|
68
|
+
index_tuple = tuple(int(i) for i in index_tuple)
|
|
69
|
+
|
|
70
|
+
# skip repeated indices
|
|
71
|
+
if len(set(index_tuple)) != len(index_tuple):
|
|
72
|
+
continue
|
|
73
|
+
# keep only strictly increasing tuples i1 < i2 < ... < ik
|
|
74
|
+
if index_tuple != tuple(sorted(index_tuple)):
|
|
75
|
+
continue
|
|
76
|
+
coeff = coupling_tensor[index_tuple]
|
|
77
|
+
if coeff == 0:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
term = qml.PauliZ(index_tuple[0])
|
|
81
|
+
for q in index_tuple[1:]:
|
|
82
|
+
term = term @ qml.PauliZ(q)
|
|
83
|
+
coeffs.append(sign * spin_sign * float(coeff))
|
|
84
|
+
obs.append(term)
|
|
85
|
+
|
|
86
|
+
return qml.Hamiltonian(coeffs, obs)
|
|
87
|
+
|
|
88
|
+
def get_mixer_hamiltonian(self):
|
|
89
|
+
"""Constructs the Mixer Hamiltonian: Σ X_i."""
|
|
90
|
+
return qml.Hamiltonian([1.0] * self.n_qubits, [qml.PauliX(i) for i in range(self.n_qubits)])
|
|
91
|
+
|
|
92
|
+
def get_state_vector(self, s: str) -> str:
|
|
93
|
+
"""Return the state vector."""
|
|
94
|
+
# Coefficients
|
|
95
|
+
alpha = self.model.calculate_alpha(couplings=self.local_couplings)
|
|
96
|
+
coeff_mixer = self.gamma
|
|
97
|
+
coeff_problem = -(1 - self.gamma) * alpha
|
|
98
|
+
|
|
99
|
+
H_total = qml.Hamiltonian([coeff_mixer] + [1.0], [self.get_mixer_hamiltonian(), self.get_problem_hamiltonian(couplings=self.local_couplings, sign=coeff_problem)])
|
|
100
|
+
|
|
101
|
+
@qml.qnode(self.dev)
|
|
102
|
+
def quantum_evolution(input_string):
|
|
103
|
+
for i, bit in enumerate(input_string):
|
|
104
|
+
if bit == "1":
|
|
105
|
+
qml.PauliX(i)
|
|
106
|
+
qml.ApproxTimeEvolution(H_total, self.time, self.num_trotter_steps)
|
|
107
|
+
return qml.state()
|
|
108
|
+
|
|
109
|
+
state_vector = quantum_evolution(s)
|
|
110
|
+
return state_vector
|
|
111
|
+
|
|
112
|
+
def get_sample_from_state_vector(self, s: str) -> str:
|
|
113
|
+
"""Returns a single sampled bitstring s' using the quantum distribution."""
|
|
114
|
+
# Get the full state vector probabilities
|
|
115
|
+
state_vector = self.get_state_vector(s) # This returns the complex amplitudes
|
|
116
|
+
probs = np.abs(state_vector) ** 2
|
|
117
|
+
|
|
118
|
+
# Sample one index based on the probabilities
|
|
119
|
+
n_states = len(probs)
|
|
120
|
+
idx = np.random.choice(n_states, p=probs)
|
|
121
|
+
|
|
122
|
+
# Convert that index back to a bitstring (e.g., 3 -> "011")
|
|
123
|
+
s_prime = np.binary_repr(idx, width=self.model.n)
|
|
124
|
+
return s_prime
|
|
125
|
+
|
|
126
|
+
def get_sample(self, s: str):
|
|
127
|
+
"""Returns a measured sample after time evolution"""
|
|
128
|
+
# Coefficients
|
|
129
|
+
alpha = self.model.calculate_alpha(n=self.spin_length, couplings=self.local_couplings)
|
|
130
|
+
coeff_mixer = self.gamma
|
|
131
|
+
coeff_problem = -(1 - self.gamma) * alpha
|
|
132
|
+
|
|
133
|
+
H_total = qml.Hamiltonian([coeff_mixer] + [1.0], [self.get_mixer_hamiltonian(), self.get_problem_hamiltonian(couplings=self.local_couplings, sign=coeff_problem)])
|
|
134
|
+
|
|
135
|
+
@qml.qnode(self.dev, shots=1)
|
|
136
|
+
def quantum_evolution(input_string):
|
|
137
|
+
for i, bit in enumerate(input_string):
|
|
138
|
+
if bit == "1":
|
|
139
|
+
qml.PauliX(i)
|
|
140
|
+
qml.ApproxTimeEvolution(H_total, self.time, self.num_trotter_steps)
|
|
141
|
+
return qml.sample()
|
|
142
|
+
|
|
143
|
+
# Get the first shot from the sample
|
|
144
|
+
sample = quantum_evolution(s)[0] # pennylane update doesnt squeeze singletons anymore
|
|
145
|
+
bitstring = "".join(str(int(b)) for b in sample)
|
|
146
|
+
return bitstring
|
|
147
|
+
|
|
148
|
+
def update(self, s, subgroup_choice, local_couplings, gamma, time) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Performs time evolution on coarse grained hamiltonian update to get s' from s
|
|
151
|
+
"""
|
|
152
|
+
self._assert_bitstring(s)
|
|
153
|
+
self.gamma = gamma
|
|
154
|
+
self.time = time
|
|
155
|
+
self.local_couplings = local_couplings
|
|
156
|
+
self.spin_length = len(subgroup_choice)
|
|
157
|
+
|
|
158
|
+
# Get s_cg' for the subgroup and reconstruct full s' using s and s_cg'
|
|
159
|
+
s_cg = "".join([s[i] for i in subgroup_choice])
|
|
160
|
+
s_cg_prime = self.get_sample(s_cg)
|
|
161
|
+
|
|
162
|
+
s_list = list(s)
|
|
163
|
+
for i, global_index in enumerate(subgroup_choice):
|
|
164
|
+
s_list[global_index] = s_cg_prime[i]
|
|
165
|
+
|
|
166
|
+
return "".join(s_list)
|
|
167
|
+
|
|
168
|
+
def _assert_bitstring(self, s, *, length=None):
|
|
169
|
+
# Accept numpy strings etc.
|
|
170
|
+
if not isinstance(s, str):
|
|
171
|
+
raise TypeError(f"bitstring must be of type str, got {type(s)}: {s!r}")
|
|
172
|
+
|
|
173
|
+
if length is not None and len(s) != length:
|
|
174
|
+
raise ValueError(f"bitstring must have length {length}, got {len(s)}: {s!r}")
|
|
175
|
+
|
|
176
|
+
bad = set(s) - {"0", "1"}
|
|
177
|
+
if bad:
|
|
178
|
+
raise ValueError(f"bitstring must contain only '0'/'1'. Bad chars: {bad}. Value: {s!r}")
|
|
179
|
+
|
|
180
|
+
return s
|
qemcmc/coarse_grain.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Internal
|
|
2
|
+
from qemcmc.utils.helpers import validate_subgroups
|
|
3
|
+
|
|
4
|
+
# External
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoarseGraining:
|
|
9
|
+
def __init__(self, n, subgroups=None, subgroup_probs=None):
|
|
10
|
+
if subgroups is None:
|
|
11
|
+
used = False
|
|
12
|
+
subgroups = [list(range(n))]
|
|
13
|
+
subgroup_probs = [1.0]
|
|
14
|
+
else:
|
|
15
|
+
used = True
|
|
16
|
+
if subgroup_probs is None:
|
|
17
|
+
subgroup_probs = [1.0 / len(subgroups)] * len(subgroups)
|
|
18
|
+
|
|
19
|
+
validate_subgroups(subgroups=subgroups, subgroup_probs=subgroup_probs, n_spins=n)
|
|
20
|
+
|
|
21
|
+
self.n = n
|
|
22
|
+
self.subgroups = subgroups
|
|
23
|
+
self.subgroup_probs = subgroup_probs
|
|
24
|
+
self.used = used
|
|
25
|
+
|
|
26
|
+
def sample(self, rng=np.random):
|
|
27
|
+
idx = rng.choice(len(self.subgroups), p=self.subgroup_probs)
|
|
28
|
+
return self.subgroups[idx]
|
qemcmc/main.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Internal
|
|
2
|
+
from qemcmc.sampler import ClassicalMCMC, QeMCMC
|
|
3
|
+
from qemcmc.utils import ModelMaker, MCMCChain
|
|
4
|
+
|
|
5
|
+
# External
|
|
6
|
+
from matplotlib import pyplot as plt
|
|
7
|
+
from joblib import Parallel, delayed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
|
|
12
|
+
def plot_chains(chains: list[MCMCChain], color: str, label: str):
|
|
13
|
+
for chain in chains:
|
|
14
|
+
energies = chain.get_current_energy_array()
|
|
15
|
+
pos = chain.get_pos_array()
|
|
16
|
+
plt.plot(pos, energies, color=color, alpha=0.3)
|
|
17
|
+
avg_energy = sum(chain.get_current_energy_array() for chain in chains) / len(chains)
|
|
18
|
+
plt.plot(pos, avg_energy, color=color, label=f"Average {label}")
|
|
19
|
+
|
|
20
|
+
n = 15
|
|
21
|
+
steps = 300
|
|
22
|
+
reps = 10
|
|
23
|
+
model_type = "Fully Connected Ising"
|
|
24
|
+
model_type2 = "Coarse Grained Ising"
|
|
25
|
+
name = "Test Ising model"
|
|
26
|
+
|
|
27
|
+
model_maker = ModelMaker(n, model_type2, name)
|
|
28
|
+
model = model_maker.model
|
|
29
|
+
cg = model_maker.cg
|
|
30
|
+
|
|
31
|
+
temp = 0.1
|
|
32
|
+
initial_states = model.initial_state
|
|
33
|
+
|
|
34
|
+
uni_chains = []
|
|
35
|
+
for rep in range(reps):
|
|
36
|
+
classical_uniform_MCMC = ClassicalMCMC(model, temp, method="uniform")
|
|
37
|
+
uni_chain = classical_uniform_MCMC.run(
|
|
38
|
+
steps,
|
|
39
|
+
initial_state=initial_states[rep],
|
|
40
|
+
name="classical uniform MCMC",
|
|
41
|
+
verbose=True,
|
|
42
|
+
sample_frequency=1,
|
|
43
|
+
)
|
|
44
|
+
uni_chains.append(uni_chain)
|
|
45
|
+
plot_chains(uni_chains, "orange", "classical uniform MCMC")
|
|
46
|
+
|
|
47
|
+
loc_chains = []
|
|
48
|
+
for rep in range(reps):
|
|
49
|
+
classical_local_MCMC = ClassicalMCMC(model, temp, method="local")
|
|
50
|
+
loc_chain = classical_local_MCMC.run(
|
|
51
|
+
steps,
|
|
52
|
+
initial_state=initial_states[rep],
|
|
53
|
+
name="classical local MCMC",
|
|
54
|
+
verbose=True,
|
|
55
|
+
sample_frequency=1,
|
|
56
|
+
)
|
|
57
|
+
loc_chains.append(loc_chain)
|
|
58
|
+
plot_chains(loc_chains, "lightgreen", "classical local MCMC")
|
|
59
|
+
|
|
60
|
+
def run_qemcmc(rep):
|
|
61
|
+
qemcmc = QeMCMC(model, gamma=(0.3, 0.6), time=(2, 20), temp=temp, coarse_graining=cg)
|
|
62
|
+
return qemcmc.run(
|
|
63
|
+
steps,
|
|
64
|
+
initial_state=initial_states[rep],
|
|
65
|
+
name="QeMCMC",
|
|
66
|
+
verbose=True,
|
|
67
|
+
sample_frequency=1,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
Qe_chains = Parallel(n_jobs=-1)(delayed(run_qemcmc)(rep) for rep in range(reps))
|
|
71
|
+
plot_chains(Qe_chains, "lightblue", "QeMCMC")
|
|
72
|
+
|
|
73
|
+
plt.xlabel("MCMC step")
|
|
74
|
+
plt.ylabel("Energy")
|
|
75
|
+
plt.title("MCMC chains")
|
|
76
|
+
plt.legend()
|
|
77
|
+
plt.show()
|
|
78
|
+
# plt.savefig("plots/MCMC_chains.png")
|
qemcmc/model/__init__.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import typing
|
|
3
|
+
from typing import List
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EnergyModel:
|
|
8
|
+
"""
|
|
9
|
+
A base class for energy models for a classical energy function over n spins,
|
|
10
|
+
defined by arbitrary-order coupling tensors.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
n:
|
|
15
|
+
Number of spins
|
|
16
|
+
couplings:
|
|
17
|
+
List of numpy arrays representing interaction tensors. A 1D array encodes linear terms,
|
|
18
|
+
a 2D array encodes pairwise terms (expected symmetric), and higher-rank tensors encode
|
|
19
|
+
higher-order interactions.
|
|
20
|
+
name:
|
|
21
|
+
Optional label for the model (used in plotting / logging).
|
|
22
|
+
cost_function_signs:
|
|
23
|
+
Sign convention(s) used by downstream components (e.g. proposal/acceptance conventions).
|
|
24
|
+
|
|
25
|
+
Notes
|
|
26
|
+
-----
|
|
27
|
+
- Energies are computed by mapping binary states ``{0,1}`` to spin values ``{-1,+1}`` internally.
|
|
28
|
+
- Brute-force methods such as ``get_all_energies`` scale as O(2^n) and are intended only for
|
|
29
|
+
small systems.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
n: int,
|
|
35
|
+
couplings: List[np.ndarray] = [],
|
|
36
|
+
name: str = None,
|
|
37
|
+
cost_function_signs: list = [-1, -1],
|
|
38
|
+
):
|
|
39
|
+
self.n = n
|
|
40
|
+
self.n_spins = n
|
|
41
|
+
self.couplings = couplings
|
|
42
|
+
self.name = name
|
|
43
|
+
self.alpha = self.calculate_alpha(n, couplings)
|
|
44
|
+
self.cost_function_signs = cost_function_signs
|
|
45
|
+
self.initial_state = []
|
|
46
|
+
|
|
47
|
+
for i in range(100):
|
|
48
|
+
self.initial_state.append("".join(str(i) for i in np.random.randint(0, 2, self.n, dtype=int)))
|
|
49
|
+
|
|
50
|
+
def calculate_energy(self, state, couplings, spin_type="binary", sign=1):
|
|
51
|
+
"""
|
|
52
|
+
Calculate the energy of a given state for an arbitrary-order Ising/QUBO model.
|
|
53
|
+
|
|
54
|
+
Parameters:
|
|
55
|
+
-----------
|
|
56
|
+
state : array-like (str, list, tuple, np.array)
|
|
57
|
+
State configuration. Can be:
|
|
58
|
+
- Binary: "011", [0,1,1], (0,1,1), etc. (uses values 0 and 1 directly)
|
|
59
|
+
- Spin: [-1,1,1], (-1,1,1), etc. (uses values -1 and +1 directly)
|
|
60
|
+
|
|
61
|
+
couplings : list of numpy arrays
|
|
62
|
+
List of coupling tensors where:
|
|
63
|
+
- 1D arrays represent linear terms (h_i)
|
|
64
|
+
- 2D arrays represent quadratic terms (J_ij)
|
|
65
|
+
- 3D arrays represent cubic terms, etc.
|
|
66
|
+
|
|
67
|
+
spin_type : str, optional (default='binary')
|
|
68
|
+
- 'binary': state uses 0/1 values
|
|
69
|
+
- 'spin': state uses -1/+1 values
|
|
70
|
+
Note: The actual values in 'state' are used directly in the calculations
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
--------
|
|
74
|
+
float : Total energy of the state
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(state, str):
|
|
77
|
+
state = np.array([int(bit) for bit in state])
|
|
78
|
+
else:
|
|
79
|
+
state = np.array(state)
|
|
80
|
+
|
|
81
|
+
if spin_type == "binary":
|
|
82
|
+
state = 2 * state - 1
|
|
83
|
+
|
|
84
|
+
total_energy = 0.0
|
|
85
|
+
for coupling in couplings:
|
|
86
|
+
coupling = np.array(coupling)
|
|
87
|
+
order = coupling.ndim
|
|
88
|
+
|
|
89
|
+
if order == 1:
|
|
90
|
+
total_energy += np.dot(coupling, state)
|
|
91
|
+
elif order == 2:
|
|
92
|
+
total_energy += np.einsum("ij, i, j->", coupling, state, state)
|
|
93
|
+
else:
|
|
94
|
+
# General case for any order >=3 (cubic, quartic etc.)
|
|
95
|
+
indices = "".join(chr(97 + i) for i in range(order)) # 'abc...', 'ijkl...'
|
|
96
|
+
einsum_str = f"{indices}," + ",".join([indices[i] for i in range(order)]) + "->"
|
|
97
|
+
total_energy += np.einsum(einsum_str, coupling, *([state] * order))
|
|
98
|
+
|
|
99
|
+
return sign * total_energy
|
|
100
|
+
|
|
101
|
+
def get_subgroup_couplings(self, subgroup: List[int], current_state: str):
|
|
102
|
+
"""
|
|
103
|
+
Calculates local couplings for a subgroup.
|
|
104
|
+
Spins outside the group are treated as frozen constants.
|
|
105
|
+
"""
|
|
106
|
+
n_sub = len(subgroup)
|
|
107
|
+
subgroup_set = set(subgroup)
|
|
108
|
+
g_to_l = {g_idx: l_idx for l_idx, g_idx in enumerate(subgroup)}
|
|
109
|
+
|
|
110
|
+
# Map bitstring '0'/'1' to spin values -1/+1
|
|
111
|
+
state_vals = np.array([1 if b == "1" else -1 for b in current_state])
|
|
112
|
+
max_order = max(c.ndim for c in self.couplings)
|
|
113
|
+
new_couplings = [np.zeros((n_sub,) * d) for d in range(1, max_order + 1)]
|
|
114
|
+
|
|
115
|
+
for coupling in self.couplings:
|
|
116
|
+
for indices in np.ndindex(coupling.shape):
|
|
117
|
+
coeff = coupling[indices]
|
|
118
|
+
if coeff == 0 or len(set(indices)) != len(indices):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
in_group = [i for i in indices if i in subgroup_set]
|
|
122
|
+
out_group = [i for i in indices if i not in subgroup_set]
|
|
123
|
+
|
|
124
|
+
# Multiply coefficient by values of fixed spins outside the subgroup
|
|
125
|
+
multiplier = np.prod(state_vals[out_group])
|
|
126
|
+
effective_coeff = coeff * multiplier
|
|
127
|
+
|
|
128
|
+
if in_group:
|
|
129
|
+
new_order = len(in_group)
|
|
130
|
+
local_indices = tuple(g_to_l[i] for i in in_group)
|
|
131
|
+
new_couplings[new_order - 1][local_indices] += effective_coeff
|
|
132
|
+
|
|
133
|
+
return new_couplings
|
|
134
|
+
|
|
135
|
+
def calculate_alpha(self, n, couplings, eps: float = 1e-15) -> float:
|
|
136
|
+
"""
|
|
137
|
+
Compute alpha = sqrt(n) / sqrt(sum of squares of UNIQUE coupling coefficients),
|
|
138
|
+
assuming coupling tensors are symmetric representations.
|
|
139
|
+
|
|
140
|
+
Any non-symmetric 2-body input raises ValueError.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
couplings : list[np.ndarray] | None
|
|
145
|
+
Couplings to use. Defaults to self.couplings.
|
|
146
|
+
eps : float
|
|
147
|
+
Small threshold to avoid division by zero.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
float : alpha
|
|
152
|
+
"""
|
|
153
|
+
if couplings is None:
|
|
154
|
+
couplings = self.couplings
|
|
155
|
+
|
|
156
|
+
norm_sq = 0.0
|
|
157
|
+
|
|
158
|
+
for T in couplings:
|
|
159
|
+
T = np.asarray(T)
|
|
160
|
+
order = T.ndim
|
|
161
|
+
|
|
162
|
+
if order == 0:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# 1-body: h_i
|
|
166
|
+
if order == 1:
|
|
167
|
+
if T.shape != (n,):
|
|
168
|
+
raise ValueError(f"1-body tensor has shape {T.shape}, expected ({n},)")
|
|
169
|
+
for i in range(n):
|
|
170
|
+
c = float(T[i])
|
|
171
|
+
norm_sq += c * c
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# 2-body: symmetric J
|
|
175
|
+
if order == 2:
|
|
176
|
+
if T.shape != (n, n):
|
|
177
|
+
raise ValueError(f"2-body tensor has shape {T.shape}, expected ({n},{n})")
|
|
178
|
+
|
|
179
|
+
# Enforce symmetry (rejects pure upper/lower triangular)
|
|
180
|
+
if not np.allclose(T, T.T):
|
|
181
|
+
raise ValueError("Non-symmetric J provided. This alpha function only accepts symmetric J.")
|
|
182
|
+
|
|
183
|
+
# Count each interaction once: i<j
|
|
184
|
+
for i in range(n):
|
|
185
|
+
for j in range(i + 1, n):
|
|
186
|
+
c = float(T[i, j])
|
|
187
|
+
if c != 0.0:
|
|
188
|
+
norm_sq += c * c
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Order >= 3: count each unordered interaction once using i1<i2<...<ik
|
|
192
|
+
if T.shape != (n,) * order:
|
|
193
|
+
raise ValueError(f"{order}-body tensor has shape {T.shape}, expected {(n,) * order}")
|
|
194
|
+
|
|
195
|
+
for comb in itertools.combinations(range(n), order):
|
|
196
|
+
c = float(T[comb])
|
|
197
|
+
if c != 0.0:
|
|
198
|
+
norm_sq += c * c
|
|
199
|
+
|
|
200
|
+
if norm_sq < eps:
|
|
201
|
+
raise ValueError("Cannot compute alpha: no nonzero (non-constant) couplings found.")
|
|
202
|
+
|
|
203
|
+
return np.sqrt(n / norm_sq)
|
|
204
|
+
|
|
205
|
+
def get_energy(self, state: str) -> float:
|
|
206
|
+
"""
|
|
207
|
+
Returns the energy of a given state
|
|
208
|
+
"""
|
|
209
|
+
if not isinstance(state, str):
|
|
210
|
+
raise TypeError(f"State must be a string, but got {type(state)}")
|
|
211
|
+
energy = self.calculate_energy(state, self.couplings)
|
|
212
|
+
return energy
|
|
213
|
+
|
|
214
|
+
def get_all_energies(self) -> np.ndarray:
|
|
215
|
+
"""
|
|
216
|
+
Calculate the energies for all possible spin states.
|
|
217
|
+
This method generates all possible spin states for the
|
|
218
|
+
system and calculates the energy for each state.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
np.ndarray: An array containing the energies of all possible spin states.
|
|
223
|
+
"""
|
|
224
|
+
self.S = ["".join(i) for i in itertools.product("01", repeat=self.n)]
|
|
225
|
+
all_energies = np.zeros(len(self.S))
|
|
226
|
+
for state in self.S:
|
|
227
|
+
all_energies[int(state, 2)] = self.calculate_energy(state, self.couplings)
|
|
228
|
+
return all_energies
|
|
229
|
+
|
|
230
|
+
def get_lowest_energies(self, num_states: int) -> typing.Tuple[np.ndarray, np.ndarray]:
|
|
231
|
+
"""
|
|
232
|
+
Retrieve the lowest energy states and their degeneracies.
|
|
233
|
+
This method computes all possible energies and then finds the specified number
|
|
234
|
+
of lowest energy states along with their degeneracies. Note that this method
|
|
235
|
+
is intended for small instances due to its brute-force nature, which is extremely
|
|
236
|
+
memory intensive and slow.
|
|
237
|
+
Args:
|
|
238
|
+
num_states (int): The number of lowest energy states to retrieve.
|
|
239
|
+
Returns:
|
|
240
|
+
Two numpy arrays:
|
|
241
|
+
- The first array contains the lowest energy values.
|
|
242
|
+
- The second array contains the degeneracies of the corresponding energy values.
|
|
243
|
+
"""
|
|
244
|
+
# only to be used for small instances, it is just brute force so extremely memory intensive and slow
|
|
245
|
+
all_energies = self.get_all_energies()
|
|
246
|
+
|
|
247
|
+
# very slow (sorts whole array)
|
|
248
|
+
self.lowest_energies, self.lowest_energy_degeneracy = self.find_lowest_values(all_energies, num_values=num_states)
|
|
249
|
+
|
|
250
|
+
return self.lowest_energies, self.lowest_energy_degeneracy
|
|
251
|
+
|
|
252
|
+
def find_lowest_values(self, arr: np.ndarray, num_values: int = 5):
|
|
253
|
+
"""
|
|
254
|
+
Find the lowest unique values in an array and their degeneracies.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
arr (np.ndarray): The input array from which to find the lowest values.
|
|
258
|
+
num_values (int, optional): The number of lowest unique values to find. Defaults to 5.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
tuple: A tuple containing two numpy arrays:
|
|
262
|
+
- lowest_values (np.ndarray): The lowest unique values in the array.
|
|
263
|
+
- degeneracy (np.ndarray): The counts of each of the lowest unique values.
|
|
264
|
+
"""
|
|
265
|
+
# Count the occurrences of each value
|
|
266
|
+
unique_values, counts = np.unique(arr, return_counts=True)
|
|
267
|
+
# Sort the unique values and counts by value
|
|
268
|
+
sorted_indices = np.argsort(unique_values)
|
|
269
|
+
unique_values_sorted = unique_values[sorted_indices]
|
|
270
|
+
counts_sorted = counts[sorted_indices]
|
|
271
|
+
# Find the first num_values
|
|
272
|
+
lowest_values = unique_values_sorted[:num_values]
|
|
273
|
+
degeneracy = counts_sorted[:num_values]
|
|
274
|
+
return lowest_values, degeneracy
|
|
275
|
+
|
|
276
|
+
def get_lowest_energy(self):
|
|
277
|
+
"""
|
|
278
|
+
Calculate and return the lowest energy from all possible energies.
|
|
279
|
+
This method uses a brute force approach to find the lowest energy,
|
|
280
|
+
making it extremely memory intensive and slow. It is recommended
|
|
281
|
+
to use this method only for small instances.
|
|
282
|
+
Returns:
|
|
283
|
+
float: The lowest energy value.
|
|
284
|
+
Notes:
|
|
285
|
+
If the lowest energy has already been calculated and stored
|
|
286
|
+
in `self.lowest_energy`, it will return that value directly
|
|
287
|
+
to save computation time.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
# Only to be used for small instances, it is just brute force so extremely memory intensive and slow
|
|
291
|
+
if self.lowest_energy is not None:
|
|
292
|
+
return self.lowest_energy
|
|
293
|
+
else:
|
|
294
|
+
all_energies = self.get_all_energies()
|
|
295
|
+
|
|
296
|
+
lowest_energy = np.min(all_energies)
|
|
297
|
+
|
|
298
|
+
return lowest_energy
|
|
299
|
+
|
|
300
|
+
def get_boltzmann_factor(self, state: str, beta: float = 1.0) -> float:
|
|
301
|
+
"""
|
|
302
|
+
Get un-normalised boltzmann probability of a given state
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
state (str): configuration of spins for which probability is to be calculated
|
|
306
|
+
beta (float): inverse temperature (1/T) at which the probability is to be calculated.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
float corresponding to the un-normalised boltzmann probability of the given state.
|
|
310
|
+
"""
|
|
311
|
+
E = self.get_energy(state)
|
|
312
|
+
r = np.exp(-1 * beta * E, dtype=np.longdouble)
|
|
313
|
+
|
|
314
|
+
return r
|
|
315
|
+
|
|
316
|
+
def get_boltzmann_factor_from_energy(self, E, beta: float = 1.0) -> float:
|
|
317
|
+
"""
|
|
318
|
+
Get un-normalized Boltzmann probability for a given energy.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
E (float): Energy for which the Boltzmann factor is to be calculated.
|
|
322
|
+
beta (float): Inverse temperature (1/T) at which the probability is to be calculated.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
float: The un-normalized Boltzmann probability for the given energy.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
return np.exp(-1 * beta * E, dtype=np.longdouble)
|