Skip to content

Commit

Permalink
Implement the Monte Carlo method of error propagation
Browse files Browse the repository at this point in the history
  • Loading branch information
astralcai committed Apr 30, 2024
1 parent efbb1b4 commit a8c5e69
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 26 deletions.
1 change: 1 addition & 0 deletions qexpy/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .experimental_value import ExperimentalValue
from .functions import correlation, covariance
from .measurement import Measurement, RepeatedMeasurement
from .monte_carlo import monte_carlo

__functions__ = [
"Measurement",
Expand Down
76 changes: 76 additions & 0 deletions qexpy/core/derived_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from functools import cached_property

import qexpy as q
from qexpy.core.experimental_value import ExperimentalValue
from qexpy.core.formula import _Formula
from qexpy.utils import Unit
Expand Down Expand Up @@ -29,20 +30,27 @@ class DerivedValue(ExperimentalValue):

def __init__(self, formula: _Formula):
self._formula = formula
self._mc = MonteCarloConfig(self._formula)
self._error_method = "auto"
super().__init__("", None)

def __copy__(self):
obj = object.__new__(DerivedValue)
obj._formula = self._formula
obj._error_method = self._error_method
obj._name = self._name
return obj

@property
def value(self) -> float:
if self.error_method == "monte-carlo":
return self._mc.value
return self._value

@property
def error(self) -> float:
if self.error_method == "monte-carlo":
return self._mc.error
return self._error

@property
Expand All @@ -60,3 +68,71 @@ def _error(self):
@cached_property
def _unit(self): # pylint: disable=method-hidden
return self._formula.unit

@property
def error_method(self) -> str:
"""The method of error propagation used for this value
QExPy supports error propagation with partial derivatives (`"derivative"`) and by using
a Monte Carlo simulation (`"monte-carlo"`). By default, the global preference for the
error method will be used, but it is also possible to configure the error method for a
single derived value. To simply use the global option, set this to `"auto"`.
Examples
--------
>>> import qexpy as q
>>> a = q.Measurement(5, 0.1)
>>> b = q.Measurement(6, 0.1)
>>> res = a * b
>>> res.error_method = "monte-carlo"
>>> print(res)
30.0 +/- 0.8
"""
if self._error_method == "auto":
return q.options.error.method
return self._error_method

@error_method.setter
def error_method(self, method: str):
if method not in ("derivative", "monte-carlo", "auto"):
raise ValueError("The error method can only be 'derivative', 'monte-carlo', or 'auto'")
self._error_method = method


class MonteCarloConfig:
"""Stores all data and configurations of a Monte Carlo simulation."""

def __init__(self, formula: _Formula):
self._formula = formula
self._sample_size = 0
self._samples = None

@property
def value(self):
return self.samples.mean()

@property
def error(self):
return self.samples.std()

@property
def sample_size(self) -> int:
"""The number of samples to use in a Monte Carlo simulation"""
if not self._sample_size:
return q.options.error.mc.sample_size
return self._sample_size

@sample_size.setter
def sample_size(self, size: int):
if size < 0:
raise ValueError("The sample size must be a positive integer!")
self._sample_size = size
self._samples = None

@property
def samples(self):
if self._samples is None:
self._samples = q.core.monte_carlo(self._formula, self.sample_size)
return self._samples
80 changes: 80 additions & 0 deletions qexpy/core/monte_carlo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Monte Carlo method of error propagation"""

from __future__ import annotations

from typing import Iterable, Dict, List

import numpy as np

import qexpy as q
from qexpy.core.formula import _Formula, _Operation, _find_measurements
from qexpy.utils import Unit


class _MeasurementSample(_Formula):
"""An array of random samples that simulates a measurement."""

def __init__(self, samples: np.ndarray, unit: Unit):
self._samples = samples
self._unit = unit

@property
def value(self) -> float | np.ndarray:
return self._samples

def _derivative(self, x: _Formula) -> float:
return 0 # pragma: no cover

@property
def unit(self) -> Unit:
return self._unit # pragma: no cover


def monte_carlo(formula: _Formula, sample_size: int) -> np.ndarray:
"""Use a Monte Carlo simulation to evaluate a formula."""

sources = _find_measurements(formula)
samples = _populate_samples(sources, sample_size)
formula = _reconstruct_formula(formula, samples)
return formula.value


def _populate_samples(
sources: Iterable[q.core.Measurement], sample_size: int
) -> Dict[q.core.Measurement, np.ndarray]:
"""Populates the samples for all measurements"""

samples = {}
sources = list(sources)
offset_matrix = np.vstack([np.random.normal(0, 1, sample_size) for _ in sources])
offset_matrix = _correlate_samples(sources, offset_matrix)
for measurement, offset in zip(sources, offset_matrix):
samples[measurement] = measurement.value + offset * measurement.error
return samples


def _correlate_samples(sources: List[q.core.Measurement], offsets: np.ndarray) -> np.ndarray:
"""Apply correlation to the offset matrix"""

corr_matrix = np.array([[q.correlation(row, col) for col in sources] for row in sources])

if np.count_nonzero(corr_matrix - np.diag(np.diagonal(corr_matrix))) == 0:
return offsets # if no correlations are present

cholesky_decomposition = np.linalg.cholesky(corr_matrix)
return np.dot(cholesky_decomposition, offsets)


def _reconstruct_formula(
formula: _Formula, samples: Dict[q.core.Measurement, np.ndarray]
) -> _Formula:
"""Reconstruct the formula from the samples"""

if isinstance(formula, q.core.Measurement):
return _MeasurementSample(samples[formula], formula.unit)

if isinstance(formula, _Operation):
operands = tuple(_reconstruct_formula(operand, samples) for operand in formula.operands)
return formula.__class__(*operands)

return formula
2 changes: 2 additions & 0 deletions tests/.pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ disable=raw-checker-failed,
cyclic-import,
protected-access,
use-implicit-booleaness-not-comparison,
duplicate-code,
no-member,
51 changes: 28 additions & 23 deletions tests/core/test_derived_value.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for ExperimentalValue arithmetic"""

# pylint: disable=no-value-for-parameter

import numpy as np
import pytest

Expand Down Expand Up @@ -278,7 +280,8 @@ class TestErrorPropagation:
lambda x, y: x / y,
],
)
def test_correlated_measurements(self, op_func):
@pytest.mark.parametrize("error_method", ["derivative", "monte-carlo"])
def test_correlated_measurements(self, op_func, error_method):
"""Tests that error propagation works correctly for correlated measurements"""

arr1 = np.array([399.3, 404.6, 394.6, 396.3, 399.6, 404.9, 387.4, 404.9, 398.2, 407.2])
Expand All @@ -291,6 +294,7 @@ def test_correlated_measurements(self, op_func):

m1.set_covariance(m2)
m = op_func(m1, m2)
m.error_method = error_method
assert m.value == pytest.approx(m_expected.value, rel=0.02)
assert m.error == pytest.approx(m_expected.error, rel=0.02)

Expand All @@ -303,12 +307,13 @@ def test_correlated_measurements(self, op_func):
lambda x, y, z: x - y / z,
],
)
def test_multiple_correlated_measurements(self, op_func):
@pytest.mark.parametrize("error_method", ["derivative", "monte-carlo"])
def test_multiple_correlated_measurements(self, op_func, error_method):
"""Tests that error propagation works for multiple correlated measurements"""

arr1 = np.array([399.3, 404.6, 394.6, 396.3, 399.6, 404.9, 387.4, 404.9, 398.2, 407.2])
arr2 = np.array([193.2, 205.1, 192.6, 194.2, 196.6, 201.0, 184.7, 215.2, 203.6, 207.8])
arr3 = np.array([93.2, 105.1, 92.6, 94.2, 96.6, 101.0, 84.7, 115.2, 103.6, 107.8])
arr3 = np.array([93.1, 105.1, 92.7, 94.2, 96.6, 101.0, 84.6, 115.3, 103.6, 107.7])
arr_expected = op_func(arr1, arr2, arr3)

m1 = q.Measurement(arr1)
Expand All @@ -320,10 +325,12 @@ def test_multiple_correlated_measurements(self, op_func):
m1.set_covariance(m3)
m2.set_covariance(m3)
m = op_func(m1, m2, m3)
m.error_method = error_method
assert m.value == pytest.approx(m_expected.value, rel=0.02)
assert m.error == pytest.approx(m_expected.error, rel=0.02)

def test_composite_formula(self):
@pytest.mark.parametrize("error_method", ["derivative", "monte-carlo"])
def test_composite_formula(self, error_method):
"""Integration test for error propagation with composite formula"""

q.define_unit("F", "C^2/(N*m)")
Expand All @@ -334,28 +341,26 @@ def test_composite_formula(self):
r = q.Measurement(0.12, relative_error=0.01, unit="m")

force = 1 / (4 * q.pi * q.eps0) * q1 * q2 / r**2
force.error_method = error_method

eps0 = 8.8541878128e-12
expected_value = 1 / (4 * np.pi * eps0) * 1.23e-6 * 2.34e-5 / 0.12**2
expected_error = np.sqrt(
(0.01 * 1.23e-6 / (4 * np.pi * eps0) * 2.34e-5 / 0.12**2) ** 2
+ (0.01 * 2.34e-5 / (4 * np.pi * eps0) * 1.23e-6 / 0.12**2) ** 2
+ (0.01 * 0.12 * 2 / (4 * np.pi * eps0) * 1.23e-6 * 2.34e-5 / 0.12**3) ** 2
+ (0.0000000013e-12 / (4 * np.pi * eps0**2) * 1.23e-6 * 2.34e-5 / 0.12**2) ** 2
)

assert isinstance(force, q.core.DerivedValue)
assert force.value == pytest.approx(
1 / (4 * np.pi * 8.8541878128e-12) * 1.23e-6 * 2.34e-5 / 0.12**2
)
assert force.error == pytest.approx(
np.sqrt(
(0.01 * 1.23e-6 / (4 * np.pi * 8.8541878128e-12) * 2.34e-5 / 0.12**2) ** 2
+ (0.01 * 2.34e-5 / (4 * np.pi * 8.8541878128e-12) * 1.23e-6 / 0.12**2) ** 2
+ (0.01 * 0.12 * 2 / (4 * np.pi * 8.8541878128e-12) * 1.23e-6 * 2.34e-5 / 0.12**3)
** 2
+ (
0.0000000013e-12
/ (4 * np.pi * 8.8541878128e-12**2)
* 1.23e-6
* 2.34e-5
/ 0.12**2
)
** 2
)
)
assert force.unit == {"kg": 1, "m": 1, "s": -2}
assert str(force.unit) == "N"

if force.error_method == "derivative":
assert force.value == pytest.approx(expected_value)
assert force.error == pytest.approx(expected_error)
else:
assert force.value == pytest.approx(expected_value, rel=0.001)
assert force.error == pytest.approx(expected_error, rel=0.005)

q.clear_unit_definitions()
2 changes: 1 addition & 1 deletion tests/core/test_experimental_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def test_equal(self):
assert x != 1.22
assert 1.22 != x

assert not x == "a"
assert x != "a"

def test_not_equal(self):
"""Tests the not equal methods of ExperimentalValue"""
Expand Down
2 changes: 1 addition & 1 deletion tests/core/test_formula.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ def value(self) -> float:
@property
def unit(self) -> Unit:
"""The unit of the formula"""
return Unit({})
return Unit({}) # pragma: no cover

def _derivative(self, x: _Formula) -> float:
return self.d if x is self.m else 0
Expand Down
2 changes: 2 additions & 0 deletions tests/core/test_functions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Unit tests for general functions in the core module"""

# pylint: disable=no-value-for-parameter

import pytest

import numpy as np
Expand Down
2 changes: 1 addition & 1 deletion tests/core/test_measurements.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Unit tests for measurements"""

# pylint: disable=too-few-public-methods
# pylint: disable=no-value-for-parameter

import pytest

Expand Down

0 comments on commit a8c5e69

Please sign in to comment.