205 lines
9.8 KiB
Python
205 lines
9.8 KiB
Python
"""Synthetic tests only; no saved scientific results are accessed."""
|
|
import copy
|
|
import json
|
|
import unittest
|
|
|
|
import numpy as np
|
|
|
|
from structure_metrics import aggregate_structure, MODES, SEEDS, METRICS
|
|
|
|
|
|
def records():
|
|
rows = []
|
|
for mode in MODES:
|
|
for odor in range(6):
|
|
design = np.zeros(686, bool)
|
|
design[odor] = True
|
|
for seed in SEEDS:
|
|
count = np.zeros(686, np.int32)
|
|
count[odor] = 10
|
|
count[10] = seed - SEEDS[0]
|
|
count[11] = odor
|
|
rows.append(dict(mode=mode, odor_index=odor, seed=seed,
|
|
counts=count, design_mask=design.copy()))
|
|
return rows
|
|
|
|
|
|
class StructureMetricsTests(unittest.TestCase):
|
|
def test_fixed_complete_inventory_and_pair_specific_baseline(self):
|
|
result = aggregate_structure(records())
|
|
self.assertEqual(result['record_count'], 60)
|
|
self.assertEqual(result['design']['design_union_count'], 6)
|
|
self.assertEqual(result['design']['common_non_design_count'], 680)
|
|
self.assertEqual(result['design']['common_non_design_column_positions'], list(range(6, 686)))
|
|
for mode in MODES:
|
|
self.assertEqual(result['modes'][mode]['trials'], 30)
|
|
for view_name in ('fixed_686', 'common_non_design'):
|
|
view = result['modes'][mode][view_name]
|
|
self.assertEqual(len(view['within_comparisons']), 60)
|
|
self.assertEqual(len(view['between_comparisons']), 75)
|
|
self.assertEqual(len(view['pairs']), 15)
|
|
for pair in view['pairs']:
|
|
self.assertEqual(len(pair['within_comparisons']), 20)
|
|
self.assertEqual(len(pair['between_comparisons']), 5)
|
|
self.assertEqual({row['odor_index'] for row in pair['within_comparisons']},
|
|
{pair['odor_index_a'], pair['odor_index_b']})
|
|
self.assertEqual([row['seed'] for row in pair['between_comparisons']], list(SEEDS))
|
|
json.dumps(result, allow_nan=False)
|
|
|
|
def test_primary_retains_design_columns_and_common_view_is_fixed(self):
|
|
result = aggregate_structure(records())['modes'][MODES[0]]
|
|
pair = result['fixed_686']['pairs'][0]
|
|
common_pair = result['common_non_design']['pairs'][0]
|
|
# Odors zero and one differ by two ten-count design axes plus one
|
|
# shared-coordinate count; same-seed variability cancels between.
|
|
self.assertEqual(pair['between']['euclidean_distance']['mean'], np.sqrt(201.))
|
|
self.assertEqual(common_pair['between']['euclidean_distance']['mean'], 1.)
|
|
self.assertEqual(pair['within']['euclidean_distance']['mean'], 2.)
|
|
self.assertEqual(common_pair['within']['euclidean_distance']['mean'], 2.)
|
|
|
|
def test_input_order_does_not_change_result(self):
|
|
rows = records()
|
|
expected = aggregate_structure(rows)
|
|
actual = aggregate_structure(list(reversed(rows)))
|
|
self.assertEqual(expected, actual)
|
|
|
|
def test_inputs_unchanged_and_readonly_arrays_accepted(self):
|
|
rows = records()
|
|
before = copy.deepcopy(rows)
|
|
for row in rows:
|
|
row['counts'].setflags(write=False)
|
|
row['design_mask'].setflags(write=False)
|
|
aggregate_structure(rows)
|
|
for old, new in zip(before, rows):
|
|
np.testing.assert_array_equal(old['counts'], new['counts'])
|
|
np.testing.assert_array_equal(old['design_mask'], new['design_mask'])
|
|
self.assertFalse(new['counts'].flags.writeable)
|
|
|
|
def test_zero_vectors_do_not_become_similarity_evidence(self):
|
|
rows = records()
|
|
for row in rows:
|
|
row['counts'].fill(0)
|
|
result = aggregate_structure(rows)
|
|
for mode in MODES:
|
|
for name in ('fixed_686', 'common_non_design'):
|
|
view = result['modes'][mode][name]
|
|
for metric in ('cosine_distance', 'pearson_correlation', 'jaccard'):
|
|
self.assertIsNone(view['within'][metric]['mean'])
|
|
self.assertEqual(view['within'][metric]['valid_comparisons'], 0)
|
|
self.assertEqual(view['between'][metric]['undefined_comparisons'], 75)
|
|
self.assertEqual(view['between']['euclidean_distance']['mean'], 0.)
|
|
json.dumps(result, allow_nan=False)
|
|
|
|
def test_constant_nonzero_vectors_have_undefined_pearson(self):
|
|
rows = records()
|
|
for row in rows:
|
|
row['counts'].fill(1)
|
|
view = aggregate_structure(rows)['modes'][MODES[0]]['fixed_686']
|
|
self.assertIsNone(view['within']['pearson_correlation']['mean'])
|
|
self.assertEqual(view['within']['jaccard']['mean'], 1.)
|
|
self.assertAlmostEqual(view['within']['cosine_distance']['mean'], 0., places=14)
|
|
|
|
def test_empty_common_population_is_nonapplicable(self):
|
|
rows = records()
|
|
for row in rows:
|
|
row['design_mask'].fill(True)
|
|
result = aggregate_structure(rows)
|
|
self.assertEqual(result['design']['common_non_design_count'], 0)
|
|
for mode in MODES:
|
|
view = result['modes'][mode]['common_non_design']
|
|
self.assertFalse(view['applicable'])
|
|
self.assertEqual(view['dimensions'], 0)
|
|
for metric in METRICS:
|
|
self.assertIsNone(view['between'][metric]['mean'])
|
|
self.assertEqual(view['within'][metric]['undefined_comparisons'], 60)
|
|
|
|
def test_int32_extrema_are_promoted_before_subtraction_and_norm(self):
|
|
rows = records()
|
|
maximum = np.iinfo(np.int32).max
|
|
for row in rows:
|
|
row['counts'].fill(0)
|
|
row['counts'][row['odor_index']] = maximum
|
|
result = aggregate_structure(rows)['modes'][MODES[0]]['fixed_686']
|
|
distance = result['pairs'][0]['between']['euclidean_distance']['mean']
|
|
self.assertAlmostEqual(distance / float(maximum), np.sqrt(2.))
|
|
self.assertEqual(result['between']['cosine_distance']['mean'], 1.)
|
|
json.dumps(result, allow_nan=False)
|
|
|
|
def test_missing_record_or_extra_repeat_rejected(self):
|
|
rows = records()
|
|
for bad in (rows[:-1], rows + [copy.deepcopy(rows[0])]):
|
|
with self.assertRaisesRegex(ValueError, 'Exactly 60'):
|
|
aggregate_structure(bad)
|
|
|
|
def test_duplicate_identity_rejected_even_with_sixty_rows(self):
|
|
rows = records()
|
|
rows[-1] = copy.deepcopy(rows[0])
|
|
with self.assertRaisesRegex(ValueError, 'Duplicate CAL identity'):
|
|
aggregate_structure(rows)
|
|
|
|
def test_mask_change_across_seed_rejected(self):
|
|
rows = records()
|
|
rows[1]['design_mask'][100] = True
|
|
with self.assertRaisesRegex(ValueError, 'mask changed across seed or mode'):
|
|
aggregate_structure(rows)
|
|
|
|
def test_mask_change_across_mode_rejected(self):
|
|
rows = records()
|
|
for row in rows:
|
|
if row['mode'] == MODES[1] and row['odor_index'] == 0:
|
|
row['design_mask'][100] = True
|
|
with self.assertRaisesRegex(ValueError, 'mask changed across seed or mode'):
|
|
aggregate_structure(rows)
|
|
|
|
def test_negative_counts_and_incorrect_dtype_or_shape_rejected(self):
|
|
changes = [('negative', lambda row: row['counts'].__setitem__(0, -1)),
|
|
('counts64', lambda row: row.update(counts=row['counts'].astype(np.int64))),
|
|
('counts_float', lambda row: row.update(counts=row['counts'].astype(np.float64))),
|
|
('shape', lambda row: row.update(counts=np.zeros(685, np.int32))),
|
|
('mask_dtype', lambda row: row.update(design_mask=row['design_mask'].astype(np.uint8))),
|
|
('mask_shape', lambda row: row.update(design_mask=np.zeros(685, bool)))]
|
|
for label, change in changes:
|
|
with self.subTest(label=label):
|
|
rows = records()
|
|
change(rows[0])
|
|
with self.assertRaises(ValueError):
|
|
aggregate_structure(rows)
|
|
|
|
def test_strict_record_schema_and_id_ranges(self):
|
|
changes = [lambda row: row.update(extra=1), lambda row: row.pop('counts'),
|
|
lambda row: row.update(mode='new_mode'), lambda row: row.update(odor_index=6),
|
|
lambda row: row.update(odor_index=True), lambda row: row.update(seed=51106),
|
|
lambda row: row.update(seed=float(SEEDS[0]))]
|
|
for change in changes:
|
|
rows = records()
|
|
change(rows[0])
|
|
with self.assertRaises(ValueError):
|
|
aggregate_structure(rows)
|
|
|
|
def test_partial_undefined_metrics_report_counts_without_imputation(self):
|
|
rows = records()
|
|
# One zero-count record per mode. Four of the 60 within comparisons
|
|
# and five of the 75 between comparisons then have undefined cosine.
|
|
for row in rows:
|
|
if row['odor_index'] == 0 and row['seed'] == SEEDS[0]:
|
|
row['counts'].fill(0)
|
|
view = aggregate_structure(rows)['modes'][MODES[0]]['fixed_686']
|
|
self.assertEqual(view['within']['cosine_distance']['undefined_comparisons'], 4)
|
|
self.assertEqual(view['between']['cosine_distance']['undefined_comparisons'], 5)
|
|
self.assertEqual(view['within']['cosine_distance']['valid_comparisons'], 56)
|
|
self.assertEqual(view['between']['cosine_distance']['valid_comparisons'], 70)
|
|
|
|
def test_one_common_channel_pearson_is_undefined(self):
|
|
rows = records()
|
|
for row in rows:
|
|
row['design_mask'][:685] = True
|
|
row['counts'][685] = row['odor_index'] + 1
|
|
view = aggregate_structure(rows)['modes'][MODES[0]]['common_non_design']
|
|
self.assertEqual(view['dimensions'], 1)
|
|
self.assertIsNone(view['between']['pearson_correlation']['mean'])
|
|
self.assertEqual(view['between']['cosine_distance']['mean'], 0.)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|