【问题标题】:Cosine Similarity in linear time using Lisp使用 Lisp 的线性时间余弦相似度
【发布时间】:2022-01-13 22:22:28
【问题描述】:

两个列表的余弦相似度可以使用 for 循环在线性时间内计算。我很好奇如何使用类似 Lisp 的语言来实现这一目标。下面是我的 Python 和 Hy (Hylang) 代码示例。

Python:

def cos_sim(A,B):
    import math as _math
    n,da,db,d = 0,0,0,0

    for a,b in zip(A,B):
        n += a*b
        da += a*a
        db += b*b

    da = _math.sqrt(da)
    db = _math.sqrt(db)
    d = da*db

    return n / (d + 1e-32)

Hy (Lisp):

(import math)

(defn l2norm [a]
    (math.sqrt (reduce + (map (fn [s](* s s)) a))))

(defn dot [a b]
   (reduce + (map * a b)))

(defn cossim [a b]
   (/ (dot a b) (* (l2norm a) (l2norm b))))

【问题讨论】:

    标签: python-3.x lisp cosine-similarity hy


    【解决方案1】:

    我很好奇如何使用类似 Lisp 的语言来实现这一点。”这实际上取决于您使用的是哪种 Lisp。在 Scheme 中,您可能会执行类似于已发布的 Hy 解决方案的操作:

    (define (cos-sim-1 u v)
      (/ (dot-prod u v)
         (* (norm u) (norm v))))
    
    (define (dot-prod u v)
      (fold-left + 0 (map * u v)))
    
    (define (norm u)
      (sqrt (fold-left (lambda (acc x) (+ acc (* x x)))
                       0
                       u)))
    

    这在时间复杂度上是线性的,但可以通过只传递一次输入来通过一个常数因子来改进它。 Scheme 提供了一个命名的let 构造,可用于将名称绑定到过程;这在这里很方便,因为它为构建点积和规范提供了一种简单的机制:

    (define (cos-sim-2 u v)
      (let iter ((u u)
                 (v v)
                 (dot-product 0)
                 (U^2 0)
                 (V^2 0))
        (if (null? u)
            (/ dot-product (sqrt (* U^2 V^2)))
            (let ((x (car u))
                  (y (car v)))
              (iter (cdr u)
                    (cdr v)
                    (+ dot-product (* x y))
                    (+ U^2 (* x x))
                    (+ V^2 (* y y)))))))
    

    这两个过程都假设输入列表具有相同的长度;添加一些验证代码来检查这一点可能很有用。请注意,fold-left 是 R6RS 方案中的标准,但其他标准为此依赖 SRFI,并且某些实现可能使用不同的名称,但fold-left 功能通常可用(可能为foldlreduce)。

    在 Common Lisp 中可以使用上面显示的任何一种基本方法来解决问题,尽管在 Common Lisp 中您将使用 labels 而不是命名为 let。但是通常会看到使用 loop 宏的 Common Lisp 解决方案。 Common Lisp 标准不保证尾调用消除(尽管一些实现确实支持),所以显式循环比在 Scheme 中更常见。 loop 宏非常强大,您可以在只传递一次输入列表时解决此问题的一种方法是:

    (defun cos-sim (u v)
      (loop :for x :in u
            :for y :in v
            :sum (* x y) :into dot-product
            :sum (* x x) :into u2
            :sum (* y y) :into y2
            :finally (return (/ dot-product (sqrt (* u2 y2))))))
    

    以下是一些交互示例:

    方案(Chez 方案):

    > (cos-sim-1 '(1 0 0) '(1 0 0))
    1
    > (cos-sim-1 '(1 0 0) '(-1 0 0))
    -1
    > (cos-sim-1 '(1 0 0) '(0 1 0))
    0
    > (cos-sim-1 '(1 1 0) '(0 1 0))
    0.7071067811865475
    
    > (cos-sim-2 '(1 0 0) '(1 0 0))
    1
    > (cos-sim-2 '(1 0 0) '(-1 0 0))
    -1
    > (cos-sim-2 '(1 0 0) '(0 1 0))
    0
    > (cos-sim-2 '(1 1 0) '(0 1 0))
    0.7071067811865475
    

    普通的 Lisp:

    CL-USER> (cos-sim '(1 0 0) '(1 0 0))
    1.0
    CL-USER> (cos-sim '(1 0 0) '(-1 0 0))
    -1.0
    CL-USER> (cos-sim '(1 0 0) '(0 1 0))
    0.0
    CL-USER> (cos-sim '(1 1 0) '(0 1 0))
    0.70710677
    

    【讨论】:

    • 我很高兴看到我创建了相同的循环;我一直打算采用我看到人们在这里使用的关键字语法,因为我认为它确实更具可读性。
    【解决方案2】:

    一个简单的选择是将 Python 版本逐字翻译为 Hy,如下所示:

    (defn cos_sim [A B]
      (import math :as _math)
      (setv [n da db d] [0 0 0 0])
    
      (for [[a b] (zip A B)]
        (+= n (* a b))
        (+= da (* a a))
        (+= db (* b b)))
    
      (setv
        da (_math.sqrt da)
        db (_math.sqrt db)
        d (* da db))
    
      (/ n (+ d 1e-32)))
    

    【讨论】:

    • 感谢您的解决方案。我想知道是否可能有一种更像 Lisp 的方式来做到这一点?或者也许这最像lisp的方式?
    【解决方案3】:

    我认为您提出的解决方案相当“笨拙”:构建几个简短易读的功能,这些功能结合到您的解决方案中。例如:

    (defun n (A B)
        (sqrt (reduce #'+ (map 'list #'* A B))))
    
    (defun da (A)
        (sqrt (reduce #'+ (map 'list #'* A A))))
    
    (defun db (B)
        (sqrt (reduce #'+ (map 'list #'* B B))))
    
    (defun cos-sim (A B)
        (let ((n (n A B))
              (da (da A))
              (db (db B)))
          (/ (* n n) (+ (* da db) 1e-32)))
    

    但是,请注意,n、da 和 db 看起来非常相似。我们可以看看是否可以将它们设为单个函数或宏。在这种情况下,带有可选第二个列表参数的函数就足够简单了。 (请注意,我以一种有点奇怪的方式定义了 n 以强调这一点,但我们可能更喜欢 not 取平方根,然后将其平方以进行最终计算。这很容易改变通过检查是否传递了可选参数(包括在下面的 Bp 中);我选择在组合函数中移动平方根)无论如何,这给了我们:

     (defun d (A &optional (B A B-p))
        (reduce #'+ (map 'list #'* A B)))
     
     (defun cos-sim (A B)
        (let ((n (d A B))
              (da (sqrt (d A)))
              (db (sqrt (d B))))
          (/ n (+ (* da db) 1e-32))))
    

    另外,使用Loop是很常见的Lisp-y,更直接类似于python:

    (defun cos-sim (A B)
        (loop for a in A
              for b in B
              sum (* a b) into n
              sum (* a a) into da
              sum (* b b) into db
              finally (return (/ n (+ (sqrt (* da db)) 1e-32)))))
    

    【讨论】:

      【解决方案4】:

      这是 Racket 中一种相当自然(我认为)的方法。本质上,这是一个折叠一对数字序列的过程,所以这就是我们所做的。请注意,这不使用显式分配,并且还将平方根提升了一个级别 (sqrt(a) * sqrt(b) = sqrt(a*b),因为求根可能很昂贵(这在实践中可能无关紧要)。它也不会奇怪地添加一个微小的浮点数,我认为这是试图将一个可能不是浮点数的值强制为浮点数?如果是这样,那是错误的方法,而且它也不需要像 Racket(和大多数 Lisps)这样的语言,它努力尽可能正确地进行算术运算。

      (define (cos-sim a b)
        ;; a and b are sequences of numbers
        (let-values ([(a^2-sum b^2-sum ab-sum)
                      (for/fold ([a^2-running 0]
                                 [b^2-running 0]
                                 [ab-running 0])
                                ([ai a] [bi b])
                        (values (+ (* ai ai) a^2-running)
                                (+ (* bi bi) b^2-running)
                                (+ (* ai bi) ab-running)))])
          (/ ab-sum (sqrt (* a^2-sum b^2-sum)))))
      

      您可以相对轻松地将其转换为类型化的球拍:

      (define (cos-sim (a : (Sequenceof Number))
                       (b : (Sequenceof Number)))
        : Number
        (let-values ([(a^2-sum b^2-sum ab-sum)
                      (for/fold ([a^2-running : Number 0]
                                 [b^2-running : Number 0]
                                 [ab-running : Number 0])
                                ([ai a] [bi b])
                        (values (+ (* ai ai) a^2-running)
                                (+ (* bi bi) b^2-running)
                                (+ (* ai bi) ab-running)))])
          (/ ab-sum (sqrt (* a^2-sum b^2-sum)))))
      

      这可能不会更快,但更麻烦。

      这可能会更快:

      (define (cos-sim/flonum (a : (Sequenceof Flonum))
                              (b : (Sequenceof Flonum)))
        : Flonum
        (let-values ([(a^2-sum b^2-sum ab-sum)
                      (for/fold ([a^2-running : Flonum 0.0]
                                 [b^2-running : Flonum 0.0]
                                 [ab-running : Flonum 0.0])
                                ([ai a] [bi b])
                        (values (+ (* ai ai) a^2-running)
                                (+ (* bi bi) b^2-running)
                                (+ (* ai bi) ab-running)))])
          (/ ab-sum (assert (sqrt (* a^2-sum b^2-sum)) flonum?))))
      

      不过,我还没有检查过。

      【讨论】:

      • 我考虑过添加一个 Racket 解决方案,但我的回答已经够长了。凭借其许多迭代形式,我很高兴有人使用 Racket 写了一个答案。
      • 虽然与原来的问题有些背道而驰,但我最初添加了小浮点数来抑制除零错误。但现在我发现这是处理异常的糟糕方法哈哈。它还解释了为什么我的一些相似性值爆炸式增长。
      【解决方案5】:

      您的 Hy 示例已经是线性时间。没有一个嵌套循环根据输入的长度乘以它们的迭代次数。可以简化以使其更易于查看

      (import math)
      
      (defn dot [a b]
         (sum (map * a b)))
      
      (defn l2norm [a]
          (math.sqrt (dot a a)))
      
      (defn cossim [a b]
         (/ (dot a b) (* (l2norm a) (l2norm b))))
      

      我认为这个版本比 Python 版本更清晰,因为它更接近数学符号。

      让我们也内联l2norm 以使循环数更易于查看。

      (defn cossim [a b]
         (/ (dot a b)
            (* (math.sqrt (dot a a))
               (math.sqrt (dot b b)))))
      

      Python 的 map() 是惰性的,所以 sum()map() 一起只循环一次。您实际上有三个循环,每个循环一个dot,并且没有一个是嵌套的。你的 Python 版本有一个循环,但它每次迭代都要进行更多的计算。从理论上讲,逐行或逐列计算都没有关系:乘法是可交换的,无论是逐行还是逐行都是相同的计算次数。

      然而,在实践中,Python 确实对函数调用有很大的开销,所以我希望使用高阶函数的 Hy 版本比在循环体中没有任何函数调用的 Python 版本要慢。这是一个常数因子减速,所以它仍然是线性时间。

      如果您想在 Python 中快速循环计算,请将您的数据放入矩阵并使用 Numpy。

      【讨论】:

        猜你喜欢
        • 2023-02-10
        • 2020-08-12
        • 2014-02-25
        • 2011-01-01
        • 2017-04-04
        • 2016-08-14
        • 2017-12-12
        • 2020-02-11
        相关资源
        最近更新 更多