Skip to content

Conversation

effigies
Copy link
Member

The quaternions.fillpositive function is intended to augment a partial quaternion to make a unit 4-vector by finding w, given (x, y, z), such that |(w, x, y, z)| == 1. Floating point precision error means that a (w, x, y, z) selected to be a unit vector can still sometimes result in |(w, x, y, z)| != 1 by a small amount. Currently if 1 - |(x, y, z)| > -3*eps, we call that close enough to zero that w = 0 will still produce a unit quaternion.

This is asymmetric, as if 1 - 3*eps < |(x, y, z)| < 1, we will get w = sqrt(1 - |(x, y, z)|) > 0, which can result in a surprising failure to preserve an affine that encodes a rotation in only one plane (see #1181 for an example). This is not exactly wrong, but the asymmetry is unsatisfying and inconsistent with fslhd at the least.

I'm starting this PR with a (failing) test to assert that a passing fillpositive() unit 3-vector, up to the precision allowed by its dtype, will result in a 0 w.

Following failing tests, I will use the change proposed in #1181 (comment) to satisfy the constraint imposed by the test.

Closes #1181.

@codecov
Copy link

codecov bot commented Jan 13, 2023

Codecov Report

Base: 92.17% // Head: 92.16% // Decreases project coverage by -0.01% ⚠️

Coverage data is based on head (aa4b017) compared to base (fc9a1c1).
Patch coverage: 100.00% of modified lines in pull request are covered.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1182      +/-   ##
==========================================
- Coverage   92.17%   92.16%   -0.01%     
==========================================
  Files          97       97              
  Lines       12341    12337       -4     
  Branches     2535     2534       -1     
==========================================
- Hits        11375    11371       -4     
  Misses        645      645              
  Partials      321      321              
Impacted Files Coverage Δ
nibabel/nifti1.py 92.76% <100.00%> (ø)
nibabel/quaternions.py 99.01% <100.00%> (ø)
nibabel/cifti2/cifti2_axes.py 98.51% <0.00%> (-0.02%) ⬇️

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

☔ View full report at Codecov.
📢 Do you have feedback about the report comment? Let us know in this issue.

@@ -69,6 +69,20 @@ def test_fillpos():
assert wxyz[0] == 0.0


@pytest.mark.parametrize('dtype', ('f4', 'f8'))
def test_fillpos_precision(dtype):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the idea that one of these 50 random vectors will generate a small negative difference from zero? Worth testing that explicitly?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be easier to read with some example of a positive and negative error? Here we are leaving it to chance whether the test fails, are we not?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be clear, you're suggesting two vectors that are exemplars of positive and negative error? Would random generation work, as long as we verify that we get them?

def make_unit_vector(shape, dtype, constraint=lambda x: True, tries=50):
    for _ in range(tries):
        vec = rand.uniform(low=-1, high=1, size=shape).astype(dtype)
        vec /= np.sqrt(vec @ vec)
        if constraint(vec):
            return vec

short = make_unit_vector((3,), dtype, lambda x: x @ x < 1)
long = make_unit_vector((3,), dtype, lambda x: x @ x > 1)

assert nq.fillpositive(short, w2_thresh)[0] == 0
assert nq.fillpositive(long, w2_thresh)[0] == 0

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me cleaner to find some examplar vectors that we know give positive and negative < threshold differences, and a > threshold difference, and then test some random vectors - rather than hoping (even with reasonable expectation) that we'll get suitable vectors.

@effigies

This comment was marked as resolved.

@effigies effigies force-pushed the enh/qform_calculation branch 2 times, most recently from 5009ad5 to 9afb5dd Compare January 15, 2023 17:58
minus = baseline * nptype(1 - np.finfo(dtype).eps)

assert nq.fillpositive(plus)[0] == 0.0
assert nq.fillpositive(minus)[0] == 0.0
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matthew-brett How's this for a deterministic test?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice - but - consider pusing to threshold to show where it fails?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. It actually fails at 2*eps, since the error compounds. Updated.

# Create random vectors, normalize to unit length, and count on floating point
# error to result in magnitudes larger/smaller than one
# This is to simulate cases where a unit quaternion with w == 0 would be encoded
# as xyz with small error, and we want to recover the w of 0
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still like this as a bit of a stress test that will hit more realistic rounding errors, and added a comment to explain more. Can drop it if it seems excessive.

@effigies
Copy link
Member Author

effigies commented Feb 3, 2023

@matthew-brett Gentle bump on this, if you get a few minutes.

Copy link
Member

@matthew-brett matthew-brett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks - nice cleanups. A couple of comments.

minus = baseline * nptype(1 - np.finfo(dtype).eps)

assert nq.fillpositive(plus)[0] == 0.0
assert nq.fillpositive(minus)[0] == 0.0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice - but - consider pusing to threshold to show where it fails?

elif magnitude > 1:
neg_error = True

assert pos_error, 'Did not encounter a case where 1 - |xyz| > 0'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But do you still want to fail the tests here, on a random criterion?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Just went ahead and checked the frequencies:

In [26]: vec = np.array([np.sqrt(np.sum(norm(gen_vec('f4'))**2)) for _ in range(10000)])

In [27]: np.unique(np.sign(1 - vec), return_counts=True)
Out[27]: (array([-1.,  0.,  1.], dtype=float32), array([  81, 6946, 2973]))

Seems negative errors are much less likely and we might have failed out for no good reason. I had assumed that the majority would be non-zero error and roughly split in either direction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Output of Nifti1Header.get_qform() returns different affine values than fslhd
2 participants