Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .clang-tidy
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# modernize-use-nodiscard 57 sites; annotating the API with
# [[nodiscard]] is a decision to make
# on purpose, not a lint fix
# modernize-use-std-numbers cannot tell an expected test value
# that happens to sit near a constant
# from a constant written out by hand
# modernize-use-trailing-return-type style the project does not use
# portability-avoid-pragma-once #pragma once is a deliberate choice
# readability-function-cognitive-* binary()/ternary() are inherently so
Expand All @@ -30,6 +33,7 @@ Checks: >
-bugprone-throwing-static-initialization,
-misc-include-cleaner,
-modernize-use-nodiscard,
-modernize-use-std-numbers,
-modernize-use-trailing-return-type,
-portability-avoid-pragma-once,
-readability-function-cognitive-complexity,
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,38 @@ f.d("y") # df/dy
>>> 1.0
```

`SScalar` is **first order only** — there is no Hessian. Where `DDScalar` offers `h`, `hm` and the second-order factories, `SScalar` has a value and a gradient keyed by name. The variable set does not have to be known in advance: an operation takes the union of the names of its operands, and `d` returns zero for a name the scalar has never seen.

```python
u = hj.SScalar.variable("x", 2.0)
v = hj.SScalar.variable("y", 3.0)

len(u * v) # the product knows both names
>>> 2
(u * v).d("z") # an unknown name is zero, not an error
>>> 0.0
(u - u).size # names survive even where the derivative cancels
>>> 1
```

Two further differences from `DDScalar` worth knowing:

- **No serialization.** `copy`, `deepcopy` and `pickle` raise `TypeError`; the `DDScalar` types support all three.
- **The order of terms in `repr` is unspecified**, because the derivatives live in an unordered map. Only the value comes first.

`eval(d)` contracts the gradient with a displacement given per name. Names missing from the displacement contribute nothing, and names the scalar does not know are ignored:

```python
u = hj.SScalar(f=3, d={"x": 1, "y": 6})

u.eval({"x": 0.5, "y": -0.25})
>>> 2.0
u.eval({"x": 0.5}) # y contributes nothing
>>> 3.5
u.eval({"w": 100.0}) # w is unknown and ignored
>>> 3.0
```

## Validation

Arguments coming from Python are validated. Out-of-range Hessian indices raise `IndexError`; inconsistent sizes raise `RuntimeError` — `eval` with the wrong number of values, `set_hm` with a mismatched shape, a negative size, a variable index outside the gradient, or padding below the current size.
Expand Down
10 changes: 6 additions & 4 deletions include/hyperjet/hyperjet.h
Original file line number Diff line number Diff line change
Expand Up @@ -1747,7 +1747,9 @@ template <typename TScalar> class SScalar {
using Data = std::unordered_map<std::string, TScalar>;

private: // variables
Scalar m_f;
// The default constructor is defaulted, so without an initializer m_f would
// be indeterminate and reading it undefined.
Scalar m_f{};
Data m_d;

private: // methods
Expand Down Expand Up @@ -1823,13 +1825,13 @@ template <typename TScalar> class SScalar {
Scalar eval(const Data &d) const {
Scalar r(m_f);

for (const auto coef : m_d) {
const auto it = d.find(coef.first);
for (const auto &[name, derivative] : m_d) {
const auto it = d.find(name);

if (it == d.end())
continue;

r += coef.second * it->second;
r += derivative * it->second;
}

return r;
Expand Down
152 changes: 152 additions & 0 deletions python/tests/test_SScalar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import numpy as np
from numpy.testing import assert_equal, assert_allclose
from copy import copy
import pickle

if __name__ == "__main__":
import os
Expand Down Expand Up @@ -65,3 +66,154 @@ def test_self_division():
assert_allclose(r.f, 1)
assert_allclose(r.d("x"), 0, atol=1e-15)
assert_allclose(r.d("y"), 0, atol=1e-15)


# The binding surface. The mathematics is characterized in
# test/src/test_sscalar.cpp; these tests pin what the bindings add on top.


def test_construction():
assert_equal(len(hj.SScalar()), 0)
assert_allclose(hj.SScalar().f, 0)

assert_allclose(hj.SScalar(f=1.5).f, 1.5)
assert_equal(len(hj.SScalar(f=1.5)), 0)

assert_allclose(hj.SScalar.constant(1.5).f, 1.5)
assert_equal(len(hj.SScalar.constant(1.5)), 0)

v = hj.SScalar.variable("a", 1.5)

assert_allclose(v.f, 1.5)
assert_equal(len(v), 1)
assert_allclose(v.d("a"), 1)

# an unknown name has a zero derivative rather than raising
assert_allclose(v.d("b"), 0)


def test_properties():
u = hj.SScalar(f=3, d={"x": 1, "y": 6})

assert_allclose(u.f, 3)
assert_equal(u.size, 2)
assert_equal(len(u), 2)


def test_repr():
# The derivatives live in an unordered map, so the order of the terms is
# unspecified. Only the value comes first and every term appears once.
text = repr(hj.SScalar(f=3, d={"x": 1, "y": 6}))

assert text.startswith("3")
assert "+1*dx" in text
assert "+6*dy" in text

# negative derivatives are printed without a plus sign
assert_equal(repr(hj.SScalar(f=1, d={"x": -2})), "1 -2*dx")


def test_eval():
u = hj.SScalar(f=3, d={"x": 1, "y": 6})

assert_allclose(u.eval({"x": 0.5, "y": -0.25}), 2.0)

# names missing from the displacement contribute nothing, unknown ones are
# ignored
assert_allclose(u.eval({"x": 0.5}), 3.5)
assert_allclose(u.eval({}), 3.0)
assert_allclose(u.eval({"w": 100.0}), 3.0)


def test_operators():
u = hj.SScalar(f=3, d={"x": 1})
v = hj.SScalar(f=4, d={"y": 2})

for op, f, dx, dy in [
(lambda: u + v, 7, 1, 2),
(lambda: u - v, -1, 1, -2),
(lambda: u * v, 12, 4, 6),
(lambda: u / v, 0.75, 0.25, -0.375),
(lambda: -u, -3, -1, 0),
]:
r = op()
assert_allclose(r.f, f)
assert_allclose(r.d("x"), dx)
assert_allclose(r.d("y"), dy)

for op, f, dx in [
(lambda: u + 2, 5, 1),
(lambda: 2 + u, 5, 1),
(lambda: u - 2, 1, 1),
(lambda: 2 - u, -1, -1),
(lambda: u * 2, 6, 2),
(lambda: 2 * u, 6, 2),
(lambda: u / 2, 1.5, 0.5),
(lambda: 2 / u, 2 / 3, -2 / 9),
(lambda: u**2, 9, 6),
(lambda: abs(-u), 3, 1),
]:
r = op()
assert_allclose(r.f, f)
assert_allclose(r.d("x"), dx)


def test_in_place_operators():
def fresh():
return hj.SScalar(f=3, d={"x": 1}), hj.SScalar(f=4, d={"y": 2})

for op, f, dx, dy in [
(lambda a, b: a.__iadd__(b), 7, 1, 2),
(lambda a, b: a.__isub__(b), -1, 1, -2),
(lambda a, b: a.__imul__(b), 12, 4, 6),
(lambda a, b: a.__itruediv__(b), 0.75, 0.25, -0.375),
]:
a, b = fresh()
r = op(a, b)
assert_allclose(r.f, f)
assert_allclose(r.d("x"), dx)
assert_allclose(r.d("y"), dy)


def test_comparison_uses_the_value_only():
a = hj.SScalar(f=1, d={"x": 100})
b = hj.SScalar(f=2, d={"y": -100})
c = hj.SScalar(f=1)

assert a < b and a <= b and b > a and b >= a and a != b
assert not (a == b)

# equal values compare equal no matter what the derivatives say
assert a == c and a <= c and a >= c

assert a < 2 and a == 1 and 2 > a and 1 == a


def test_numpy_aliases_agree():
u = hj.SScalar(f=0.3, d={"x": 1})
v = hj.SScalar(f=0.4, d={"y": 1})

for alias, name in [
("arccos", "acos"),
("arcsin", "asin"),
("arctan", "atan"),
("arccosh", "acosh"),
("arcsinh", "asinh"),
("arctanh", "atanh"),
]:
w = hj.SScalar(f=1.5, d={"x": 1}) if name == "acosh" else u
assert_allclose(getattr(w, alias)().f, getattr(w, name)().f)
assert_allclose(getattr(w, alias)().d("x"), getattr(w, name)().d("x"))

assert_allclose(u.arctan2(v).f, u.atan2(v).f)


def test_no_serialization():
# Unlike the DDScalar bindings, SScalar has neither pickle nor copy support.
u = hj.SScalar(f=3, d={"x": 1})

with pytest.raises(TypeError):
copy(u)

with pytest.raises(TypeError):
pickle.dumps(u)
Loading
Loading