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
29 changes: 24 additions & 5 deletions code/saga.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# Distributions
from scipy.stats import chi2, norm
# Numpy stuff
from numpy import cov, set_printoptions, diag, array, mean
from numpy import cov, set_printoptions, diag, array, mean, ndarray, generic
from numpy.linalg import matrix_rank, inv, eig, eigh
import matplotlib.pyplot as plt

Expand Down Expand Up @@ -62,6 +62,25 @@
set_printoptions(precision=4)


def _jsonable(obj):
"""
Recursively convert numpy scalars and arrays to plain Python types.

The test batteries return numpy scalars (np.bool_ for `passes`, np.float64
for statistics), which json.dumps refuses. Applying this to the output of
to_dict() keeps both to_dict() and to_json() usable for batch analysis.
"""
if isinstance(obj, dict):
return {key: _jsonable(value) for key, value in obj.items()}
if isinstance(obj, (list, tuple)):
return [_jsonable(value) for value in obj]
if isinstance(obj, ndarray):
return _jsonable(obj.tolist())
if isinstance(obj, generic): # any numpy scalar (bool_, int64, ...)
return obj.item()
return obj


def gaussian(x, mu, sigma):
"""
Gaussian function of center mu and "standard deviation" sigma.
Expand Down Expand Up @@ -235,7 +254,7 @@ def effect_sizes(self):

def to_dict(self):
es = self.effect_sizes()
return {
return _jsonable({
"test": "univariate",
"params": {
"mu": self.exp_mu, "sigma": self.exp_sigma,
Expand All @@ -260,7 +279,7 @@ def to_dict(self):
"effect_sizes": es,
"extended_tests": self._extended,
"is_valid_extended": getattr(self, 'is_valid_extended', None),
}
})

def to_json(self):
return json.dumps(self.to_dict(), indent=2)
Expand Down Expand Up @@ -403,7 +422,7 @@ def __repr__(self):
return rep

def to_dict(self):
return {
return _jsonable({
"test": "multivariate",
"params": {"sigma": self.exp_si, "dim": self.dim, "n": self.nsamples},
"doornik_hansen": {"stat": float(self.DH), "pvalue": float(self.PO)},
Expand All @@ -415,7 +434,7 @@ def to_dict(self):
"total": self.dim,
"applicable": self.is_integer},
"extended_tests": self._mv_extended,
}
})

def to_json(self):
return json.dumps(self.to_dict(), indent=2)
Expand Down
11 changes: 11 additions & 0 deletions code/tests/test_multivariate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Test SAGA multivariate analysis against generated test vectors."""
import json
import sys
import os

Expand Down Expand Up @@ -50,6 +51,16 @@ def test_extended_battery_passes(self, good_multivariate_vector):
if isinstance(r, dict) and not r.get("passes", True))
)

def test_to_json_after_battery(self, good_multivariate_vector):
"""to_dict()/to_json() must survive the battery's numpy scalars."""
v = good_multivariate_vector
mv = MultivariateSamples(v["params"]["sigma"], v["samples"])
mv.run_multivariate_battery()
parsed = json.loads(mv.to_json())
assert parsed["test"] == "multivariate"
assert parsed["extended_tests"]["all_pass"] is True
json.dumps(mv.to_dict()) # a user dumping to_dict() directly also works

def test_per_coordinate_channel(self, good_multivariate_vector):
"""Integer (discrete-Gaussian) good vectors must light up the
per-coordinate discrete channel; continuous vectors skip it."""
Expand Down
12 changes: 12 additions & 0 deletions code/tests/test_univariate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Test SAGA univariate analysis against generated test vectors."""
import json
import sys
import os

Expand Down Expand Up @@ -66,6 +67,17 @@ def test_extended_battery_default_samples(self, good_univariate_vector):
assert ext["all_pass"]
assert uv.is_valid_extended

def test_to_json_after_battery(self, good_univariate_vector):
"""to_dict()/to_json() must survive the battery's numpy scalars."""
v = good_univariate_vector
uv = UnivariateSamples(v["params"]["mu"], v["params"]["sigma"], v["samples"])
uv.run_extended_battery(samples=v["samples"], mc_B=100)
# to_dict() must be plain Python, to_json() must not raise
parsed = json.loads(uv.to_json())
assert parsed["test"] == "univariate"
assert parsed["extended_tests"]["all_pass"] is True
json.dumps(uv.to_dict()) # a user dumping to_dict() directly also works


class TestBadVectors:
"""Flawed distributions must be detected by chi-square or extended battery."""
Expand Down
Loading