计算向量之间的有符号角度的数值稳定方法

4
关于几何中的数值稳定性,这是我在Stack Exchange上的第一个问题。我编写了下面这个函数来计算从一个向量到另一个向量的有符号角度,灵感来自于这里提出的想法,我对此深感感激。
在这个函数中,三重积用于从第一部分得到的原始锐角转化为从第一个向量到第二个向量打开的有向角度。
import numpy as np
from numpy import ndarray, sign
from math import pi

Vec3D = ndarray

def directed_angle(
        from_vec: Vec3D,
        to_vec: Vec3D,
        normal: Vec3D,
        debug=False):

    """ returns the [0, 2) directed angle opening up from the first to the second vector,
    given a 3rd vector linearly independent of the first two vectors, which is used to provide
    the directionality of the acute (regular, i.e. undirected) angle firstly computed """

    assert np.any(from_vec) and np.any(to_vec), \
           f'from_vec, to_vec, and normal must not be zero vectors, but from_vec: {from_vec}, to_vec: {to_vec}'

    magnitudes = np.linalg.norm(from_vec) * np.linalg.norm(to_vec)
    dot_product = np.matmul(from_vec, to_vec)
    angle = np.arccos(np.clip(dot_product / magnitudes, -1, 1))

    # the triplet product reflects the sign of the angle opening up from the first vector to the second,
    # based on the input normal value providing the directionality which is otherwise totally abscent
    triple_product = np.linalg.det(np.array([from_vec, to_vec, normal]))

    if triple_product < 0:
        result_angle = angle
    else:
        result_angle = 2*pi - angle

    # flip the output range from (0, 2] to [0, 2) which is the prefernce for our semantics
    if result_angle == 2*pi:
        result_angle = 0

    if debug:
        print(f'\nfrom_vec: {from_vec}, '
              f'\nto_vec: {to_vec}'
              f'\nnormal: {normal}, '
              f'\nundirected angle: {angle},'
              f'\nundirected angle/pi: {angle/pi}, '
              f'\ntriple product: {triple_product}'
              f'\ntriple product sign: {sign(triple_product)}'
              f'\nresult_angle/pi: {result_angle/pi}')

    return result_angle



def test():

    v0 = np.array([1, 0, 0])
    normal = np.array([0, 0, 1])

    vecs = dict(
        same_direction = np.array([1, 0, 0]),
        opposite_direction = np.array([-1, 0, 0]),
        close_to_same_direction_from_side_1 = np.array([1, 0.00000001, 0]),
        close_to_same_direction_from_side_2 = np.array([1, -0.00000001, 0]))

    for desc, v in vecs.items():
        print(f'\n{desc}:')
        directed_angle(v0, v, normal, debug=True)

当在上面的示例测试代码中,给定的第二个向量的角度非常接近于第一个给定向量的角度,无论是从其两侧,而不是得到结果角度分别非常接近于0和非常接近于2,函数的结果实际上是零,因此这两个语义上不同的向量方向产生相同的输出角度。
在这种意义上,您将如何使该函数在数值上稳定?
假设有向角度从0增长到接近2的数字,您将如何避免函数的输出角度在无限接近完整圆形有向角度时跳至0?只要其输出是无限精确的,我们就没问题,但如果它从接近2变为0,当它无限接近2时――这种不连续的值跳跃对于我的应用程序以及我猜其他许多情况下对其输出的解释都是灾难性的。
显然,我们可以通过实证找到接近2的角度函数输出为0的上界边界――实际上下面附有一个这样的函数――但是当输入是产生该角度的向量时,这一点毫无帮助(我们无法从向量中得知它将产生一个翻转到0的角度,因此当函数的结果角度为0时――我们无法确定真实的角度是0还是接近2的值)。
我想你可以用任何一个词来替换“有向角”的术语,比如“顺时针(或逆时针)角度”。
以下是诊断函数。
def test_monotonicity(debug=False, interpolation_interval=1e-4):

    """ test that the function is monotonic (up to the used interpolation interval),
    and correct, across the interval of its domain ([0, 2)) """

    v0 = np.array([1, 0, 0])
    normal = np.array([0, 0, 1])

    # range from 0 to 2 at 0.1 intervals
    angles = np.arange(0, 2*pi, step=interpolation_interval)
    prev_angle = None
    for angle in angles:

        # make a vector representing the current angle away from v0, such that it goes clockwise
        # away from v0 as `angle` increases (clockwise assuming you look at the two vectors from
        # the perspective of the normal vector, otherwise directionality is void)
        vec = np.array([np.cos(angle), -np.sin(angle), 0])
        result_angle = directed_angle(v0, vec, normal)

        if debug:  print(f'angle/pi: {angle/pi}, computed angle/pi: {result_angle/pi}')

        if prev_angle is None:
            assert angle == 0
        else:
            assert angle > prev_angle
            drift = result_angle - angle
            if angle == result_angle:
                pass
            else:
                print(f'angle/pi: {angle/pi}, result_angle/pi: {result_angle/pi}, drift/pi: {drift/pi}')
                assert isclose(result_angle, angle, rel_tol=1e-6)

        prev_angle = angle


def test_demonstrating_the_concern():

    """ demonstration of sufficiently small angles from both sides of the reference vector (v0)
    collapsing to zero, which is a problem only when the angle should be close to 2 pi """

    v0 = np.array([1, 0, 0])
    normal = np.array([0, 0, 1])

    vecs = dict(
        same_direction = np.array([1, 0, 0]),
        opposite_direction = np.array([-1, 0, 0]),
        close_to_same_direction_from_side_1 = np.array([1, +0.00000001, 0]),
        close_to_same_direction_from_side_2 = np.array([1, -0.00000001, 0]))

    expected_results = [0, 1*pi, None, None]

    for (desc, v), expected_result in zip(vecs.items(), expected_results):
        print(f'\n{desc}:')
        v = v / np.linalg.norm(v)
        result = directed_angle(v0, v, normal, debug=True)
        if expected_result is not None:
            assert(result == expected_result)


def test_angle_approaching_2pi(epsilon=1e-7, interpolation_interval=1e-10, verbose=True):

    """ stress test the angle values approaching 2pi where the result flips to zero """

    v0 = np.array([1, 0, 0])
    normal = np.array([0, 0, 1])

    # range from 2-epsilon to close to 2
    angles = np.arange(2*pi-epsilon, 2*pi, step=interpolation_interval)
    prev_angle = None
    for angle in angles:

        # vector representing the current angle away from v0, clockwise
        vec = np.array([np.cos(angle), -np.sin(angle), 0])
        result_angle = directed_angle(v0, vec, normal)

        if prev_angle is None:
            pass
        else:
            assert angle > prev_angle
            drift = result_angle - angle
            if angle == result_angle:
                pass
            else:
                if verbose: print(f'angle/pi: {angle / pi}, result_angle/pi: {result_angle / pi}, drift/pi: {drift / pi}')
                if result_angle == 0:
                    print(f'function angle output hit 0 at angle: {angle};'
                          f'\nangle/pi: {angle / pi}'
                          f'\nangle distance from 2: {2*pi - angle}')
                    break
                else:
                    assert isclose(result_angle, angle, rel_tol=1e-6)

        prev_angle = angle

而且说了这么多,我也很高兴能够了解可能在这个函数中出现的其他数值稳定性问题。我看到有关更喜欢使用反正切函数而不是反余弦函数及其变体的评论,但不知道它们是否完全适用于numpy,是否会引入其他稳定性缺陷。
同时,非常感谢对任何关于思考数值稳定性的一般方法论或特定于numpy的安全编写数值稳定计算的指导方针的评论;尽管我意识到不同的应用程序将担心不同的范围,在这些范围内的不准确性对它们的特定逻辑产生不利影响。
由于我输入到这个函数中的数据来自机器学习预测,即使在单次调用函数时它们的概率很低,我也无法避免极端向量值的出现。
谢谢!

我认为数值瓶颈始于使用np.linalg.norm进行归一化步骤:np.linalg.norm(np.array([1, 1e-8, 0],)) == np.linalg.norm(np.array([1, 0, 0])) 结果为True。如果是1e-7或者"更小",则结果为False - cards
我认为数字瓶颈始于归一化步骤,使用 np.linalg.normnp.linalg.norm(np.array([1, 1e-8, 0],)) == np.linalg.norm(np.array([1, 0, 0])) 结果为 True。如果是 1e-7 或 "更小" 则结果为 False - cards
我认为数值瓶颈始于归一化步骤中的np.linalg.normnp.linalg.norm(np.array([1, 1e-8, 0],)) == np.linalg.norm(np.array([1, 0, 0])) 结果为True。如果是1e-7或者更小,则结果为False - undefined
3个回答

2
适应numpy的这种计算方法后,我所有的数值稳定性问题似乎都得到了完美满足。
angle = 2 * np.arctan2(
                norm(from_vec / norm(from_vec) - to_vec / norm(to_vec)),
                norm(from_vec / norm(from_vec) + to_vec / norm(to_vec)))

完整的功能代码和测试:
import numpy as np
from numpy import ndarray, sign
from math import pi, isclose
from numpy.linalg import norm

Vec3D = ndarray

def directed_angle_base(
        from_vec: Vec3D,
        to_vec: Vec3D,
        normal: Vec3D,
        debug=False):

    """ returns the [0, 2) directed angle opening up from the first to the second vector,
    given a 3rd vector linearly independent of the first two vectors, which is used to provide
    the directionality of the acute (regular, i.e. undirected) angle firstly computed. """

    assert np.any(from_vec) and np.any(to_vec), \
           f'from_vec, to_vec, and normal must not be zero vectors, but from_vec: {from_vec}, to_vec: {to_vec}'

    angle = 2 * np.arctan2(
        norm(from_vec / norm(from_vec) - to_vec /norm(to_vec)),
        norm(from_vec / norm(from_vec) + to_vec / norm(to_vec)))

    # the triplet product reflects the sign of the angle opening up from the first vector to the second,
    # based on the input normal value providing the directionality which is otherwise totally abscent
    triple_product = np.linalg.det(np.array([from_vec, to_vec, normal]))

    if triple_product < 0:
        result_angle = angle
    else:
        result_angle = 2*pi - angle

    # flip the output range from (0, 2] to [0, 2) which is the prefernce for our semantics
    if result_angle == 2*pi:
        if debug: print(f'angle output flipped from 2 to zero')
        result_angle = 0

    if debug:
        print(f'\nfrom_vec: {from_vec}, '
              f'\nto_vec: {to_vec}'
              f'\nnormal: {normal}, '
              f'\nundirected angle: {angle},'
              f'\nundirected angle/pi: {angle/pi}, '
              f'\ntriple product: {triple_product}'
              f'\ntriple product sign: {sign(triple_product)}'
              f'\nresult_angle/pi: {result_angle/pi}')

    return result_angle


def test_monotonicity(debug=False, interpolation_interval=1e-4):

    """ tests that the function is monotonic (up to the used interpolation interval),
    and correct, across the interval of its domain from 0 up to only 2-ɛ, ɛ being
    implied by the interpolation interval (so it doesn't hit the value flipping from
    2-ɛ to zero but only checks we are correct and monotonic up to a certain ɛ
    away from that value """

    v0 = np.array([1, 0, 0])
    normal = np.array([0, 0, 1])

    # range from 0 to 2 at 0.1 intervals
    angles = np.arange(0, 2*pi, step=interpolation_interval)
    prev_angle = None
    for angle in angles:

        # make a vector representing the current angle away from v0, such that it goes clockwise
        # away from v0 as `angle` increases (clockwise assuming you look at the two vectors from
        # the perspective of the normal vector, otherwise directionality is void)
        vec = np.array([np.cos(angle), -np.sin(angle), 0])
        result_angle = directed_angle_base(v0, vec, normal)

        if debug:  print(f'angle/pi: {angle/pi}, computed angle/pi: {result_angle/pi}')

        if prev_angle is None:
            assert angle == 0
        else:
            assert angle > prev_angle
            drift = result_angle - angle
            if angle == result_angle:
                pass
            else:
                print(f'angle/pi: {angle/pi}, result_angle/pi: {result_angle/pi}, drift/pi: {drift/pi}')
                assert isclose(result_angle, angle, rel_tol=1e-6)

        prev_angle = angle


def test_near_2pi_concern():

    """ demonstration of sufficiently small angles from both sides of the reference vector (v0)
    collapsing to zero, which was a problem only when the angle was infinitesimally close to 2
    with the naive acos/atan formula, before switching to Velvel Kahan's formula like we have now
    (https://dev59.com/SmwUookBPGXWitrZoiDr#76722358) """

    v0 = np.array([1, 0, 0])
    normal = np.array([0, 0, 1])

    vecs = dict(
        same_direction = np.array([1, 0, 0]),
        opposite_direction = np.array([-1, 0, 0]),
        close_to_same_direction_from_side_1 = np.array([1, +0.00000001, 0]),  # should be 2-ɛ
        close_to_same_direction_from_side_2 = np.array([1, -0.00000001, 0]))  # should be +ɛ

    expected_results = [0, 1*pi, None, None]

    for (desc, v), expected_result in zip(vecs.items(), expected_results):
        print(f'\n{desc}:')
        v = v / np.linalg.norm(v)
        result = directed_angle_base(v0, v, normal, debug=True)
        if expected_result is not None:
            assert(result == expected_result)


def test_angle_approaching_2pi(epsilon=1e-11, interpolation_interval=1e-15, verbose=True):

    """ stress test the angle values approaching 2pi where the result flips to zero """

    v0 = np.array([1, 0, 0])
    normal = np.array([0, 0, 1])

    # range from 2-epsilon to close to 2
    angles = np.arange(2*pi-epsilon, 2*pi, step=interpolation_interval)
    prev_angle = None
    for angle in angles:
        if angle > 2*pi:
            print(f'arange output value larger than 2: {angle}, ignoring')
            continue

        # vector representing the current angle away from v0, clockwise
        vec = np.array([np.cos(angle), -np.sin(angle), 0])
        result_angle = directed_angle_base(v0, vec, normal)

        if prev_angle is None:
            pass
        else:
            assert angle > prev_angle
            drift = result_angle - angle
            if verbose: print(f'angle/pi: {angle / pi}, result_angle/pi: {result_angle / pi}, drift/pi: {drift / pi}')
            if angle == result_angle:
                pass
            else:
                if result_angle == 0:
                    print(f'function angle output hit 0 at angle: {angle};'
                          f'\nangle/pi: {angle / pi}'
                          f'\nangle distance from 2: {2*pi - angle}')
                    return angle, vec
                else:
                    assert isclose(result_angle, angle, rel_tol=1e-6)

        prev_angle = angle


def test_smallest_angle():
    v0 = np.array([1, 0, 0])
    angle = 1.999999999999999*pi
    vec = np.array([np.cos(angle), -np.sin(angle), 0])
    normal = np.array([0, 0, 1])
    result_angle = directed_angle_base(v0, vec, normal, debug=True)
    print(f'angle/pi: {angle/pi}, result_angle/pi: {result_angle/pi}')
    assert result_angle == angle

声明以上测试不测试函数在最小可能浮点区间上的单调性(但我假设这是肯定的),以及您可能会或不会偏爱其他需要带符号角度函数的实现的其他稳定性特性。对我来说,它似乎解决了在无穷小值ε的情况下在值2-ε处翻转为零的痛点,同时通过了我的其他测试,而且我希望在进一步使用此函数进行应用时不会遇到其他稳定性挑战。


0
第一个非解决方案:
def directed_angle(
    from_vec: Vec3D,
    to_vec: Vec3D,
    normal: Vec3D,
    epsilon=1e-7,
    debug=False):

    """ returns the directed angle while overriding for the case where the base 
calculation returns 0, in which case we return 2-ε instead if checking shows 
that the two vectors do not point in the same direction """

    base_angle_calculation = directed_angle_base(from_vec, to_vec, normal, debug=debug)

    if base_angle_calculation == 0:
        # same direction vectors?
        divided = from_vec / to_vec
        if isclose(divided[0], divided[1]) and isclose(divided[1], divided[2]):
            return 0
        else:
            # bounce back to an arbitrary, caller chosen, angle value of 2-ε; this maintains
            # the desired semantics whereby we want to avoid flipping from 2-ε to zero; but
            # it does mean that if epsilon is set too large, we give away the monotonicity of
            # the function in the case that some other angle that does cause our function to
            # flip to 0 is smaller than 2-ε (even though the latter is larger than the former,
            # our function result for it will be smaller than the other angle's result)
            return 2*pi - epsilon

这是错误的,因为两个原因:
1. 它需要事先确定触发从2-ε到零值翻转的阈值角度(对于浮点数标准实现的无知,我不知道在不同处理器或numpy设置上是否保证该阈值角度相同,因此这意味着在启动时需要进行校准步骤,以找到该阈值角度)。
2. 很难判断检查两个向量是否具有相同方向的各种方法(正如这种方法在代码中所依赖的那样)在与该函数使用它们的上下文中是否非常数值稳定。
如果这种方法有任何前景,我很乐意学习。

请注意,您正在使用 math.isclose 而不是 np.isclose - cards
请注意,您正在使用math.isclose而不是np.isclose - cards
在这种情况下,我看不出来这有什么问题或者影响。 - matanster
无法看到这在这种情况下是错误的或重要的。 - matanster
无论你尝试运行代码并得到错误,都不要放弃。 - cards
无论你尝试运行代码并出现错误,只要你努力就好。 - cards

0
第二个被放弃的想法:
"承认限制并将其减少到最低" ―
  1. 对于给定的输入,将基础角度计算函数使用两次 - 一次使用原始向量 (v1),第二次使用一个大幅旋转的版本的 v1(比如说,v1 顺时针旋转 /4)。对于第二次调用的基础函数输出,将结果逆时针旋转回 /4。

  2. 现在我们有了两个结果。只有其中一个结果可能接近从 2-ε 到零的“翻转区域”。

  3. 因此,我们可以通过检查这两个结果是否相差大约 2-ε 来完成。

然而,这又假设在旋转向量时不会遇到数值稳定性问题,所以被放弃...

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接