在Python中实现3D向量:numpy vs x,y,z字段

12

我正在Python中实现一个3D向量类。我的向量具有x、y和z坐标(所有坐标为浮点数),我需要决定如何存储这些信息。我至少可以看到三个选项:

1)创建三个单独的浮点字段:self.x、self.y、self.z。

class Vector:

  def __init__(self, x, y, z):
    self.x = x
    self.y = y
    self.z = z

2)创建一个列表,称为self.data,其中包含三个元素。如果这些对象是常量,也可以使用元组。

class Vector:

  def __init__(self, x, y, z):
    self.data = [x,y,z]

3) 创建一个numpy数组,命名为self.data,包含三个元素。

import numpy as np    

class Vector:

  def __init__(self, x, y, z):
    self.data = np.array([x,y,z])
对于选项(2)和(3),我可以实现属性和设置器来访问单个坐标。
@property
def x(self):
  return self.data[0]

4) 为什么不需要冗余呢?我可以同时拥有一个列表(或元组、numpy数组)和单独的字段x、y和z。

该类旨在执行常见操作,如向量加法、内积、叉积、旋转等。这些操作的性能需要考虑在内。

是否存在我应该偏好的解决方案,为什么?


1
如果你可以简单地创建一个numpy数组np.array([x,y,z]),那么就没有必要创建一个Vector类。如果你有数百个向量,你也可以使用np.array([a,x,y,z])并利用NumPy广播。 - Nils Werner
http://stackoverflow.com/questions/43522840/tracking-python-2-7-x-object-attributes-at-class-level-to-quickly-construct-nump 探索了一种替代方案,即将所有对象的值存储在一个class数组中,并使用property来访问每个对象的值。 - hpaulj
3个回答

22

这个问题有不同的方面,我能给你一些关于如何解决这个问题的提示。请注意,这些只是建议,你需要自己选择最喜欢的。

支持线性代数

你提到你想要支持线性代数,比如向量加法(逐元素加法)、叉积和内积。这些在 numpy.ndarray 中是可用的,因此你可以选择不同的方法来支持它们:

  1. 简单地使用 numpy.ndarray,不必担心你自己的类:

    import numpy as np
    vector1, vector2 = np.array([1, 2, 3]), np.array([3, 2, 1])
    np.add(vector1, vector2)      # vector addition
    np.cross(vector1, vector2)    # cross product
    np.inner(vector1, vector2)    # inner product
    

    numpy中没有内置的向量旋转定义,但是有多个来源可用,例如"旋转3D向量"。所以你需要自己实现它。

    您可以创建一个类,独立于如何存储属性,并提供一个__array__方法。这样,您就可以支持(所有)numpy函数,就好像您的实例本身就是numpy.ndarray一样:

    class VectorArrayInterface(object):
        def __init__(self, x, y, z):
            self.x, self.y, self.z = x, y, z
    
        def __array__(self, dtype=None):
            if dtype:
                return np.array([self.x, self.y, self.z], dtype=dtype)
            else:
                return np.array([self.x, self.y, self.z])
    
    vector1, vector2 = VectorArrayInterface(1, 2, 3), VectorArrayInterface(3, 2, 1)
    np.add(vector1, vector2)      # vector addition
    np.cross(vector1, vector2)    # cross product
    np.inner(vector1, vector2)    # inner product
    

    这将返回与第一种情况相同的结果,因此您可以提供numpy函数的接口,而不需要numpy数组。如果在类中存储了numpy数组,则__array__方法可以简单地返回它,因此这可能是将xyz作为numpy.ndarray在内部存储的参数的理由(因为这基本上是“免费”的)。

  2. 您可以子类化np.ndarray。我不会在这里深入讨论,因为这是一个高级主题,可能需要单独一篇答案来解释。如果您真的考虑过这个问题,那么您应该查看“Subclassing ndarray”的官方文档。我不建议这样做,我曾经处理过几个子类np.ndarray的类,并且在这条路上有几个“粗糙之处”。

  3. 您可以自己实现所需的操作。这是重新发明轮子,但如果只有少数操作,这很有教育意义和有趣。我不建议在严肃的生产环境中使用这种方法,因为在这里也有几个“粗糙之处”,这些问题在numpy函数中已经得到了解决。例如溢出或下溢问题,函数的正确性等。

    可能的实现(不包括旋转)可以像这样看起来(这次使用内部存储列表):

  4. class VectorList(object):
        def __init__(self, x, y, z):
            self.vec = [x, y, z]
    
        def __repr__(self):
            return '{self.__class__.__name__}(x={self.vec[0]}, y={self.vec[1]}, z={self.vec[2]})'.format(self=self)
    
        def __add__(self, other):
            x1, y1, z1 = self.vec
            x2, y2, z2 = other.vec
            return VectorList(x1+x2, y1+y2, z1+z2)
    
        def crossproduct(self, other):
            x1, y1, z1 = self.vec
            x2, y2, z2 = other.vec
            return VectorList(y1*z2 - z1*y2,
                              z1*x2 - x1*z2,
                              x1*y2 - y1*x1)
    
        def scalarproduct(self, other):
            x1, y1, z1 = self.vec
            x2, y2, z2 = other.vec
            return x1*x2 + y1*y2 + z1*z2
    
    注意:您可以实现这些can-code方法并实现我之前提到的__array__方法。这样,您就可以支持任何期望numpy.ndarray的函数,并拥有自己的自定义方法。这些方法不是互斥的,但会产生不同的结果。上述方法返回标量或向量,但如果您通过__array__进行操作,则会返回numpy.ndarray。
    使用包含3D向量的库是一种方法。在某种程度上,这是最简单的方法,在其他方面可能非常复杂。好处是现有类可能可以直接使用,并且在性能方面可能已经优化。另一方面,您需要找到支持您用例的实现,您需要阅读文档(或通过其他手段弄清楚其工作原理),您可能会遇到错误或限制,这可能对项目产生严重影响。此外,您还需要附加依赖项,并检查许可证是否与您的项目兼容。另外,如果您复制了实现(请检查许可证是否允许!),则需要维护(即使只是同步)外部代码。
    性能
    在这种情况下,性能很棘手,所提到的用例相当简单,每个任务应该是微秒级别的 - 因此您应该能够每秒执行数千到数百万次操作。假设您不引入不必要的瓶颈!但是您可以微调操作。
    让我从一些一般性提示开始:
    避免numpy.ndarray <-> list / float操作。这些非常昂贵!如果大多数操作使用numpy.ndarray,则不要将值存储为列表或单独的属性。同样,如果您想访问Vector的各个值、遍历这些值或将它们作为列表执行操作,则将它们存储为列表或单独的属性。
    对三个值使用numpy相对效率较低。numpy.ndarray非常适用于大数组,因为它可以更有效地存储值(空间),并且比纯Python操作更具可扩展性。然而,这些优点有一些开销,在小数组(例如,长度<<100,这是经过教育的猜测,而不是固定数字!)中很显着。针对此类小型数组的Python解决方案(我使用了我已经介绍过的解决方案)可能比numpy解决方案快得多:
    class VectorArray:
        def __init__(self, x, y, z):
            self.data = np.array([x,y,z])
    
    # addition: python solution 3 times faster
    %timeit VectorList(1, 2, 3) + VectorList(3, 2, 1)  
    # 100000 loops, best of 3: 9.48 µs per loop
    %timeit VectorArray(1, 2, 3).data + VectorArray(3, 2, 1).data  
    # 10000 loops, best of 3: 35.6 µs per loop
    
    # cross product: python solution 16 times faster
    v = Vector(1, 2, 3)
    a = np.array([1,2,3])  # using a plain array to avoid the class-overhead
    %timeit v.crossproduct(v)  
    # 100000 loops, best of 3: 5.27 µs per loop
    %timeit np.cross(a, a)     
    # 10000 loops, best of 3: 84.9 µs per loop
    
    # inner product: python solution 4 times faster
    %timeit v.scalarproduct(v)  
    # 1000000 loops, best of 3: 1.3 µs per loop
    %timeit np.inner(a, a)      
    # 100000 loops, best of 3: 5.11 µs per loop
    

    然而,正如我所说,这些时间是微秒级别的,因此这确实是微小优化。但是,如果你的重点是优化你的类的性能,那么使用纯Python和自实现函数可能会更快。

    只要您尝试执行大量线性代数运算,您就应该利用NumPy的向量化操作。其中大多数与您描述的类不兼容,可能需要完全不同的方法:例如,存储按正确方式与NumPy函数接口的数组矢量(多维数组)的类!但我认为这超出了本回答的范围,并且无法真正回答您的问题,因为您的问题仅涉及存储3个值的类。

    我使用相同的方法测试了不同的方法,但这有点欺骗性。一般来说,您不应计时一个函数调用,而是应测量程序的执行时间。在程序中,微小的函数速度差异,该函数被调用数百万次,可以比只调用几次的方法的大速度差异产生更大的差异...或者不会!因为您没有分享您的程序或用例,所以我只能提供功能的时间,因此您需要找出哪种方法对您最好(正确性和性能)。

    还有其他几个因素需要考虑,哪种方法最好,但这些因素更多是“元”原因,与您的程序没有直接关系。

    • 重新发明轮子(自己实现函数)是一个学习的机会。您需要确保其正确工作,可以计时它,如果速度太慢,则可以尝试不同的优化方式。您开始思考算法复杂度、常数因子、正确性等,而不是考虑“哪个函数将解决我的问题”或“如何使那个NumPy函数正确解决我的问题”。
    • 对于长度为3的数组使用NumPy可能就像“用大炮打苍蝇”,但这是一个很好的机会来更加熟悉NumPy函数,并且在未来,即使NumPy对于此问题和答案并不适合,在向量化、索引、广播等方面你也会更加了解NumPy的工作原理。
    • 尝试不同的方法,看看进展如何。在回答这个问题时,学到了很多东西,尝试了不同的方法-比较结果的差异,计时方法调用并评估它们的限制!


1
如果你想要速度,又不想操作向量数组,那么你应该选择列表。但这只适用于最小的数组;而且永远无法扩展到整体竞争性能。NumPy 的精髓在于学会思考和编写向量化操作的代码;如果你来自类 C 循环的世界,这肯定需要投入时间;但如果你不想跨越到这种不同的思维风格,最好完全避开 Python。 - Eelco Hoogendoorn
抱歉;我按下了回车键,希望我修改后的立场更加清晰。 - Eelco Hoogendoorn
1
@EelcoHoogendoorn 您完全正确,但这个问题是关于长度为3的列表与长度为3的数组,正如时间表所显示的那样,这是列表获胜的领域(而数组甚至不接近,它们慢3-20倍)。如果问题是关于“向量数组”或长度为100的向量,我的答案会非常不同。缩放不是问题的一部分(并且将其包装在类中表明这里不打算进行缩放)。 - MSeifert
1
@MSeifert,我注意到您在加法中删除了map的使用,并用“解包”代替,然后分别添加组件。我想知道是否有特定的原因喜欢其中一种方法。您是不是得到了更快的加法?或者解包更易读,因此更受青睐? - Antonio
1
@Antonio Both,这样更快,更易读。我还添加了标量积和叉积方法,并且看起来最好实现它们的方式相同,对于叉积,使用map方法会很丑陋。请注意,我以一种允许将数组用作xyz的方式实现了这些方法。因此,如果您想要有向量数组,您可以使用vec = Vector(np.arange(100), np.arange(100), np.arange(100)); vec.scalarproduct(vec)(只是一个例子),它仍然可以工作。我今天没有时间更新答案,但我会看看是否可以在明天澄清一下。 :) - MSeifert
显示剩余3条评论

1
如果您的目标是简单的向量类型行为,请坚持使用纯numpy解决方案。有很多原因:
  • numpy已经提供了基本行为的开箱即用解决方案(包括叉积等)
  • 对于具有可感知大小的数组(即重要的情况),它将比跨越界限的解决方案快得多
  • 一旦您习惯了/熟悉了,向量化/数组语法往往更加紧凑和表达力强
  • 最重要的是:整个numpy/scipy生态系统都是围绕ndarray提供的接口构建的;所有库都使用ndarray的通用语言进行交互;使用自定义向量类型与它们进行交互将会进入痛苦的世界。

1
我也同意这种看法。然而,我有一个特别的动机去实现这样一个特定的类。是否我的动机是合理的则完全是另一个问题(我可能会决定在 StackOverflow 上提问,但那将是一个不同的线程)。 - Antonio
我很想看到那个动机; 我也偶尔遇到这些情况,但大多数时候我可以找到一个绕过那种推理的方法,如果我能做到,我总是非常高兴。 - Eelco Hoogendoorn
1
这个应用程序没有利用数组操作。数组只有3个元素长,并且分别位于不同的对象中。 - hpaulj
即使这些设计选择是硬性的,也有办法绕过去;比如将3个向量视图放在一个连续的数组上。 - Eelco Hoogendoorn

1
考虑到使用 Vector 类,我更喜欢选择选项3。因为它生成 numpy 数组,使用 numpy 进行向量操作相对容易、直观且快速。
In [81]: v1 = Vector(1.0, 2.0, 3.0)

In [82]: v2 = Vector(0.0, 1.0, 2.0)

In [83]: v1.data + v2.data
Out[83]: array([1.0, 3.0, 5.0])

In [85]: np.inner(v1.data, v2.data)
Out[85]: 8.0

这些操作在numpy中已经被很好地优化以提高性能。

好的,我想我会选择那个。我担心构建numpy.array的开销可能大于我从加速操作中获得的收益。 - Antonio
@Antonio添加了一些timeit结果。请看一下! - kmario23
2
@kmario23 你的计时方法有严重缺陷。你的计时差别只是将列表转换为数组所需的时间(8次)。两个计时仍然具有numpy操作(和转换)的开销...如果你修复了计时问题,我很乐意取消踩踏,因为除此之外,答案是好的! - MSeifert

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