From cea7dd8a4c151c1e6deeab4506316836cc50bfb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimir=20Vargas=20Calder=C3=B3n?= Date: Mon, 16 Mar 2026 16:46:48 -0500 Subject: [PATCH 1/2] Add estimation of optimal number of training steps and sampling size to guarantee fully-visible GRBM convergence --- .gitignore | 5 +- .../plugins/torch/models/boltzmann_machine.py | 212 +++++++++++++ tests/test_boltzmann_machine.py | 300 ++++++++++++++++++ 3 files changed, 516 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 39c649a..e0bf60d 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,7 @@ venv.bak/ dmypy.json # aim -*.aim* \ No newline at end of file +*.aim* + +# mlruns +**/mlruns \ No newline at end of file diff --git a/dwave/plugins/torch/models/boltzmann_machine.py b/dwave/plugins/torch/models/boltzmann_machine.py index ff8739f..4c62590 100644 --- a/dwave/plugins/torch/models/boltzmann_machine.py +++ b/dwave/plugins/torch/models/boltzmann_machine.py @@ -26,6 +26,7 @@ from __future__ import annotations +import math import warnings from typing import TYPE_CHECKING, Hashable, Iterable, Literal, Optional, Union, overload @@ -676,3 +677,214 @@ def estimate_beta(self, spins: torch.Tensor) -> float: bqm = BinaryQuadraticModel.from_ising(*self.to_ising(1)) beta = 1 / mple(bqm, (spins.detach().cpu().numpy(), self._nodes))[0] return beta + + def estimate_data_n_samples(self, delta: float, target_precision: float) -> int: + """Estimate the number of data samples that should be used to estimate energy expectations + to within a target precision with probability at least 1 - delta. + + Uses Hoeffding's inequality to estimate the number of samples required to ensure that the + empirical mean of the sufficient statistics of the data is within an additive error of + ``target_precision`` of the true mean with probability at least 1 - delta. More precisely, + if :math:`\kappa` is the target precision, :math:`\delta` is the probability of the estimate + being outside the target precision, :math:`B` is the maximum absolute value of the + sufficient statistics, and :math:`m` is the number of parameters in the model, then the + number of samples required is at least + + .. math:: + M \geq \frac{2B^2}{\kappa^2}\log\left(\frac{2m}{\delta}\right). + + Args: + delta (float): The probability of the estimate being outside the target precision. Must + be between 0 and 1. + target_precision (float): The desired maximum additive error of the energy expectation + estimate. + + Returns: + int: The estimated number of data samples required. + """ + # Maximum absolute value of the sufficient statistics is upper bounded by the maximum + # absolute value of the linear and quadratic biases. This is because the sufficient + # statistics are simply the spins and interactions of the model and the spins and + # interactions are bounded by 1 in absolute value, but the linear and quadratic biases can + # be arbitrarily large and thus can scale the sufficient statistics arbitrarily: + B = max(self.linear.abs().max(), self.quadratic.abs().max()).item() + m = self.n_edges + self.n_nodes # Number of parameters + if not (0 < delta < 1): + raise ValueError(f"delta must be between 0 and 1. You passed delta={delta}") + if target_precision <= 0: + raise ValueError( + "target_precision must be a positive float. You passed " + f"target_precision={target_precision}" + ) + return math.ceil( + (2.0 * B * B / (target_precision * target_precision)) * math.log((2.0 * m) / delta) + ) + + def estimate_optimal_learning_rate( + self, + data_target_precision: float, + grbm_target_precision: float, + difference_target_precision: float, + ) -> float: + """Estimate the optimal learning rate for training a fully-visible GRBM. + + See https://doi.org/10.1038/s42005-024-01763-x for more details. + + Args: + data_target_precision (float): The desired maximum additive error of the energy + expectation estimate for the data term. + grbm_target_precision (float): The desired maximum additive error of the energy + expectation estimate for the GRBM term. + difference_target_precision (float): The desired maximum additive error of the + difference between the data and GRBM terms. + + Returns: + float: The estimated optimal learning rate. + """ + if data_target_precision <= 0: + raise ValueError( + "data_target_precision must be a positive float. You passed " + f"data_target_precision={data_target_precision}" + ) + if grbm_target_precision <= 0: + raise ValueError( + "grbm_target_precision must be a positive float. You passed " + f"grbm_target_precision={grbm_target_precision}" + ) + if difference_target_precision <= 0: + raise ValueError( + "difference_target_precision must be a positive float. You passed " + f"difference_target_precision={difference_target_precision}" + ) + noise_power = data_target_precision**2 + grbm_target_precision**2 + m = self.n_edges + self.n_nodes # Number of parameters + gamma = difference_target_precision / (4 * m * m * noise_power) + return gamma + + def estimate_opt_steps( + self, + delta0: float, + data_target_precision: float, + grbm_target_precision: float, + difference_target_precision: float, + ) -> int: + """Estimate number of optimisation steps to guarantee convergence of fully-visible GRBM + training. + + See https://doi.org/10.1038/s42005-024-01763-x for more details. + + Args: + delta0 (float): Initial quantum relative entropy difference between the untrained model + and the optimal model. Must be positive. In the paper by Coopmans and Benedetti, + delta0 is around 3 for a small 8-qubit model. + data_target_precision (float): The desired maximum additive error of the energy + expectation estimate for the data term. + grbm_target_precision (float): The desired maximum additive error of the energy + expectation estimate for the GRBM term. + difference_target_precision (float): The desired maximum additive error of the + difference between the data and GRBM terms. + + Returns: + int: The estimated number of optimization steps. + """ + if delta0 <= 0: + raise ValueError(f"delta0 must be positive. You passed delta0={delta0}") + if data_target_precision <= 0: + raise ValueError( + f"data_target_precision must be positive. You passed " + f"data_target_precision={data_target_precision}" + ) + if grbm_target_precision <= 0: + raise ValueError( + f"grbm_target_precision must be positive. You passed " + f"grbm_target_precision={grbm_target_precision}" + ) + if difference_target_precision <= 0: + raise ValueError( + f"difference_target_precision must be positive. You passed " + f"difference_target_precision={difference_target_precision}" + ) + m = self.n_edges + self.n_nodes # Number of parameters + noise_power = data_target_precision**2 + grbm_target_precision**2 + return math.ceil(48 * delta0 * m * m * noise_power / (difference_target_precision**4)) + + def estimate_grbm_n_samples( + self, + grbm_target_precision: float, + success_probability: float, + total_opt_steps: int | None = None, + delta0: float | None = None, + data_target_precision: float | None = None, + difference_target_precision: float | None = None, + ) -> int: + """Estimate the number of GRBM samples required to guarantee convergence of fully-visible + GRBM training with a given success probability. + + See https://doi.org/10.1038/s42005-024-01763-x for more details. + + Args: + grbm_target_precision (float): The desired maximum additive error of the energy + expectation estimate for the GRBM term. + success_probability (float): The desired probability of successful convergence. + total_opt_steps (int | None, optional): The total number of optimization steps. If None, + it will be estimated based on the other parameters. Defaults to None. + delta0 (float | None, optional): The initial quantum relative entropy difference between + the untrained model and the optimal model. Must be a positive float if + total_opt_steps is None. Defaults to None. + data_target_precision (float | None, optional): The desired maximum additive error of + the energy expectation estimate for the data term. Must be a positive float if + total_opt_steps is None. Defaults to None. + difference_target_precision (float | None, optional): The desired maximum additive error + of the difference between the data and GRBM terms. Must be a positive float if + total_opt_steps is None. Defaults to None. + + Returns: + int: The estimated number of GRBM samples required. + """ + if grbm_target_precision <= 0: + raise ValueError( + "grbm_target_precision must be a positive float. You passed " + f"grbm_target_precision={grbm_target_precision}" + ) + if not (0 < success_probability < 1): + raise ValueError( + f"success_probability must be between 0 and 1. You passed " + f"success_probability={success_probability}" + ) + + if total_opt_steps is None: + if delta0 is None: + raise ValueError("delta0 must be provided if total_opt_steps is not provided.") + if data_target_precision is None: + raise ValueError( + "data_target_precision must be provided if total_opt_steps is not provided." + ) + if difference_target_precision is None: + raise ValueError( + "difference_target_precision must be provided if total_opt_steps is not " + "provided." + ) + if delta0 <= 0: + raise ValueError(f"delta0 must be positive. You passed delta0={delta0}") + if data_target_precision <= 0: + raise ValueError( + "data_target_precision must be a positive float. You passed " + f"data_target_precision={data_target_precision}" + ) + if difference_target_precision <= 0: + raise ValueError( + "difference_target_precision must be a positive float. You passed " + f"difference_target_precision={difference_target_precision}" + ) + total_opt_steps = self.estimate_opt_steps( + delta0=delta0, + data_target_precision=data_target_precision, + grbm_target_precision=grbm_target_precision, + difference_target_precision=difference_target_precision, + ) + m = self.n_edges + self.n_nodes # Number of parameters + return math.ceil( + 1 + / (grbm_target_precision**4) + * math.log(m / (1 - success_probability ** (1 / total_opt_steps))) + ) diff --git a/tests/test_boltzmann_machine.py b/tests/test_boltzmann_machine.py index 069cadf..e6dd85d 100644 --- a/tests/test_boltzmann_machine.py +++ b/tests/test_boltzmann_machine.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import math import unittest import torch @@ -128,6 +129,305 @@ def test_estimate_beta(self): self.bm.estimate_beta(spins), ) + def test_estimate_data_n_samples(self): + with self.subTest("Matches Hoeffding-based sample estimate"): + delta = 0.1 + target_precision = 0.5 + B = max(self.bm.linear.abs().max(), self.bm.quadratic.abs().max()).item() + m = self.bm.n_edges + self.bm.n_nodes + expected = math.ceil( + (2.0 * B * B / (target_precision * target_precision)) * math.log((2.0 * m) / delta) + ) + self.assertEqual(expected, self.bm.estimate_data_n_samples(delta, target_precision)) + + for bad_delta in (0.0, 1.0, -0.1, 1.1): + with self.subTest(f"Invalid delta raises for delta={bad_delta}"): + with self.assertRaisesRegex(ValueError, "delta must be between 0 and 1"): + self.bm.estimate_data_n_samples(bad_delta, 0.5) + + for bad_target_precision in (0.0, -0.1): + with self.subTest( + f"Invalid target_precision raises for target_precision={bad_target_precision}" + ): + with self.assertRaisesRegex( + ValueError, "target_precision must be a positive float" + ): + self.bm.estimate_data_n_samples(0.1, bad_target_precision) + + def test_estimate_optimal_learning_rate(self): + with self.subTest("Matches closed-form learning-rate estimate"): + data_target_precision = 0.1 + grbm_target_precision = 0.2 + difference_target_precision = 0.05 + noise_power = data_target_precision**2 + grbm_target_precision**2 + m = self.bm.n_edges + self.bm.n_nodes + expected = difference_target_precision / (4 * m * m * noise_power) + self.assertEqual( + expected, + self.bm.estimate_optimal_learning_rate( + data_target_precision, + grbm_target_precision, + difference_target_precision, + ), + ) + + for bad_data_precision in (0.0, -0.1): + with self.subTest(f"Invalid data_target_precision raises for {bad_data_precision}"): + with self.assertRaisesRegex( + ValueError, "data_target_precision must be a positive float" + ): + self.bm.estimate_optimal_learning_rate(bad_data_precision, 0.2, 0.05) + + for bad_grbm_precision in (0.0, -0.1): + with self.subTest(f"Invalid grbm_target_precision raises for {bad_grbm_precision}"): + with self.assertRaisesRegex( + ValueError, "grbm_target_precision must be a positive float" + ): + self.bm.estimate_optimal_learning_rate(0.1, bad_grbm_precision, 0.05) + + for bad_diff_precision in (0.0, -0.1): + with self.subTest( + f"Invalid difference_target_precision raises for {bad_diff_precision}" + ): + with self.assertRaisesRegex( + ValueError, "difference_target_precision must be a positive float" + ): + self.bm.estimate_optimal_learning_rate(0.1, 0.2, bad_diff_precision) + + def test_estimate_opt_steps(self): + with self.subTest("Matches closed-form optimization-steps estimate"): + delta0 = 3.0 + data_target_precision = 0.1 + grbm_target_precision = 0.2 + difference_target_precision = 0.05 + m = self.bm.n_edges + self.bm.n_nodes + noise_power = data_target_precision**2 + grbm_target_precision**2 + expected = math.ceil( + 48 * delta0 * m * m * noise_power / (difference_target_precision**4) + ) + self.assertEqual( + expected, + self.bm.estimate_opt_steps( + delta0, + data_target_precision, + grbm_target_precision, + difference_target_precision, + ), + ) + + with self.subTest("Invalid delta0 raises"): + with self.assertRaisesRegex(ValueError, "delta0 must be positive"): + self.bm.estimate_opt_steps(0.0, 0.1, 0.2, 0.05) + + with self.subTest("Invalid data_target_precision raises"): + with self.assertRaisesRegex(ValueError, "data_target_precision must be positive"): + self.bm.estimate_opt_steps(3.0, 0.0, 0.2, 0.05) + + with self.subTest("Invalid grbm_target_precision raises"): + with self.assertRaisesRegex(ValueError, "grbm_target_precision must be positive"): + self.bm.estimate_opt_steps(3.0, 0.1, 0.0, 0.05) + + with self.subTest("Invalid difference_target_precision raises"): + with self.assertRaisesRegex(ValueError, "difference_target_precision must be positive"): + self.bm.estimate_opt_steps(3.0, 0.1, 0.2, 0.0) + + def test_estimate_grbm_n_samples(self): + with self.subTest("Matches closed-form estimate with explicit total_opt_steps"): + grbm_target_precision = 0.2 + success_probability = 0.9 + total_opt_steps = 10 + m = self.bm.n_edges + self.bm.n_nodes + expected = math.ceil( + 1 + / (grbm_target_precision**4) + * math.log(m / (1 - success_probability ** (1 / total_opt_steps))) + ) + self.assertEqual( + expected, + self.bm.estimate_grbm_n_samples( + grbm_target_precision, + success_probability, + total_opt_steps=total_opt_steps, + ), + ) + + with self.subTest("Computes total_opt_steps when not provided"): + grbm_target_precision = 0.2 + success_probability = 0.9 + delta0 = 3.0 + data_target_precision = 0.1 + difference_target_precision = 0.05 + total_opt_steps = self.bm.estimate_opt_steps( + delta0, + data_target_precision, + grbm_target_precision, + difference_target_precision, + ) + m = self.bm.n_edges + self.bm.n_nodes + expected = math.ceil( + 1 + / (grbm_target_precision**4) + * math.log(m / (1 - success_probability ** (1 / total_opt_steps))) + ) + self.assertEqual( + expected, + self.bm.estimate_grbm_n_samples( + grbm_target_precision, + success_probability, + delta0=delta0, + data_target_precision=data_target_precision, + difference_target_precision=difference_target_precision, + ), + ) + + for bad_grbm_precision in (0.0, -0.1): + with self.subTest(f"Invalid grbm_target_precision raises for {bad_grbm_precision}"): + with self.assertRaisesRegex( + ValueError, "grbm_target_precision must be a positive float" + ): + self.bm.estimate_grbm_n_samples(bad_grbm_precision, 0.9, total_opt_steps=10) + + for bad_success_probability in (0.0, 1.0, -0.1, 1.1): + with self.subTest( + "Invalid success_probability raises for " + f"success_probability={bad_success_probability}" + ): + with self.assertRaisesRegex( + ValueError, "success_probability must be between 0 and 1" + ): + self.bm.estimate_grbm_n_samples( + 0.2, bad_success_probability, total_opt_steps=10 + ) + + with self.subTest("Missing delta0 when total_opt_steps is omitted raises"): + with self.assertRaisesRegex( + ValueError, "delta0 must be provided if total_opt_steps is not provided" + ): + self.bm.estimate_grbm_n_samples( + 0.2, + 0.9, + data_target_precision=0.1, + difference_target_precision=0.05, + ) + + with self.subTest("Missing data_target_precision when total_opt_steps is omitted raises"): + with self.assertRaisesRegex( + ValueError, + "data_target_precision must be provided if total_opt_steps is not provided", + ): + self.bm.estimate_grbm_n_samples( + 0.2, + 0.9, + delta0=3.0, + difference_target_precision=0.05, + ) + + with self.subTest( + "Missing difference_target_precision when total_opt_steps is omitted raises" + ): + with self.assertRaisesRegex( + ValueError, + "difference_target_precision must be provided if total_opt_steps is not provided", + ): + self.bm.estimate_grbm_n_samples( + 0.2, + 0.9, + delta0=3.0, + data_target_precision=0.1, + ) + + with self.subTest("Invalid delta0 raises when total_opt_steps is omitted"): + with self.assertRaisesRegex(ValueError, "delta0 must be positive"): + self.bm.estimate_grbm_n_samples( + 0.2, + 0.9, + delta0=0.0, + data_target_precision=0.1, + difference_target_precision=0.05, + ) + + with self.subTest("Invalid data_target_precision raises when total_opt_steps is omitted"): + with self.assertRaisesRegex( + ValueError, "data_target_precision must be a positive float" + ): + self.bm.estimate_grbm_n_samples( + 0.2, + 0.9, + delta0=3.0, + data_target_precision=0.0, + difference_target_precision=0.05, + ) + + with self.subTest( + "Invalid difference_target_precision raises when total_opt_steps is omitted" + ): + with self.assertRaisesRegex( + ValueError, "difference_target_precision must be a positive float" + ): + self.bm.estimate_grbm_n_samples( + 0.2, + 0.9, + delta0=3.0, + data_target_precision=0.1, + difference_target_precision=0.0, + ) + + def test_estimation_monotonicity_with_relaxed_requirements(self): + with self.subTest("Data sample estimate decreases as target precision is relaxed"): + strict = self.bm.estimate_data_n_samples(delta=0.1, target_precision=0.1) + relaxed = self.bm.estimate_data_n_samples(delta=0.1, target_precision=0.3) + self.assertLess(relaxed, strict) + + with self.subTest("Data sample estimate decreases as required success probability drops"): + # estimate_data_n_samples uses delta (failure probability), so lower success + # probability corresponds to larger delta. + high_success = self.bm.estimate_data_n_samples(delta=0.01, target_precision=0.2) + low_success = self.bm.estimate_data_n_samples(delta=0.1, target_precision=0.2) + self.assertLess(low_success, high_success) + + with self.subTest("GRBM sample estimate decreases as target precision is relaxed"): + strict = self.bm.estimate_grbm_n_samples( + grbm_target_precision=0.1, + success_probability=0.9, + total_opt_steps=20, + ) + relaxed = self.bm.estimate_grbm_n_samples( + grbm_target_precision=0.2, + success_probability=0.9, + total_opt_steps=20, + ) + self.assertLess(relaxed, strict) + + with self.subTest("GRBM sample estimate decreases as required success probability drops"): + high_success = self.bm.estimate_grbm_n_samples( + grbm_target_precision=0.2, + success_probability=0.99, + total_opt_steps=20, + ) + low_success = self.bm.estimate_grbm_n_samples( + grbm_target_precision=0.2, + success_probability=0.9, + total_opt_steps=20, + ) + self.assertLess(low_success, high_success) + + with self.subTest( + "Estimated optimization steps decrease when precision targets are relaxed" + ): + strict_steps = self.bm.estimate_opt_steps( + delta0=3.0, + data_target_precision=0.05, + grbm_target_precision=0.05, + difference_target_precision=0.05, + ) + relaxed_steps = self.bm.estimate_opt_steps( + delta0=3.0, + data_target_precision=0.2, + grbm_target_precision=0.2, + difference_target_precision=0.2, + ) + self.assertLess(relaxed_steps, strict_steps) + def test_pad(self): grbm = GRBM([0, 1, 2], [(0, 1), (0, 2), (1, 2)], [1]) x = torch.zeros((99, 2)) From 02abf008fc31f588663898d20a66413cd71735fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimir=20Vargas=20Calder=C3=B3n?= Date: Mon, 16 Mar 2026 16:56:17 -0500 Subject: [PATCH 2/2] Point out specific references for the Coopmans and Benedetti paper in the docstrings --- dwave/plugins/torch/models/boltzmann_machine.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/dwave/plugins/torch/models/boltzmann_machine.py b/dwave/plugins/torch/models/boltzmann_machine.py index 4c62590..edf2f12 100644 --- a/dwave/plugins/torch/models/boltzmann_machine.py +++ b/dwave/plugins/torch/models/boltzmann_machine.py @@ -28,7 +28,8 @@ import math import warnings -from typing import TYPE_CHECKING, Hashable, Iterable, Literal, Optional, Union, overload +from typing import (TYPE_CHECKING, Hashable, Iterable, Literal, Optional, + Union, overload) import torch @@ -39,7 +40,8 @@ from hybrid.composers import AggregatedSamples from dwave.plugins.torch.utils import sampleset_to_tensor -from dwave.system.temperatures import maximum_pseudolikelihood_temperature as mple +from dwave.system.temperatures import \ + maximum_pseudolikelihood_temperature as mple spread = AggregatedSamples.spread @@ -679,10 +681,11 @@ def estimate_beta(self, spins: torch.Tensor) -> float: return beta def estimate_data_n_samples(self, delta: float, target_precision: float) -> int: - """Estimate the number of data samples that should be used to estimate energy expectations + r"""Estimate the number of data samples that should be used to estimate energy expectations to within a target precision with probability at least 1 - delta. - Uses Hoeffding's inequality to estimate the number of samples required to ensure that the + Uses ``_ to + estimate the number of samples required to ensure that the empirical mean of the sufficient statistics of the data is within an additive error of ``target_precision`` of the true mean with probability at least 1 - delta. More precisely, if :math:`\kappa` is the target precision, :math:`\delta` is the probability of the estimate @@ -728,7 +731,7 @@ def estimate_optimal_learning_rate( ) -> float: """Estimate the optimal learning rate for training a fully-visible GRBM. - See https://doi.org/10.1038/s42005-024-01763-x for more details. + See ``_ for more details. Args: data_target_precision (float): The desired maximum additive error of the energy @@ -771,7 +774,7 @@ def estimate_opt_steps( """Estimate number of optimisation steps to guarantee convergence of fully-visible GRBM training. - See https://doi.org/10.1038/s42005-024-01763-x for more details. + See ``_ for more details. Args: delta0 (float): Initial quantum relative entropy difference between the untrained model @@ -820,7 +823,7 @@ def estimate_grbm_n_samples( """Estimate the number of GRBM samples required to guarantee convergence of fully-visible GRBM training with a given success probability. - See https://doi.org/10.1038/s42005-024-01763-x for more details. + See ``_ for more details. Args: grbm_target_precision (float): The desired maximum additive error of the energy