Roadmap / NumPy & Math Foundations

✓

Pairwise Distances with Broadcasting

mediumnumpybroadcasting

The problem

Models represent words, documents and images as vectors: lists of DD numbers, where similar things get vectors that are close to each other. A very common job is to compare every vector of one set with every vector of another, for example to find which stored documents are closest to a question (retrieval, section 15). The result is a table of distances: one row per vector of the first set, one column per vector of the second.

You get two tables of vectors:

  • a, with NN rows: row ii is the vector aia_i;
  • b, with MM rows: row jj is the vector bjb_j;
  • both have DD columns, one per coordinate.

Return the N×MN \times M table whose entry (i,j)(i, j) is the squared distance between aia_i and bjb_j.

Squared distance

To compare two vectors: subtract them coordinate by coordinate, square each difference, and add up.

Dij=∥ai−bj∥2=∑d=1D(aid−bjd)2\mathrm{D}_{ij} = \lVert a_i - b_j \rVert^2 = \sum_{d=1}^{D} (a_{id} - b_{jd})^2

where:

  • aida_{id} is coordinate dd of vector aia_i, and bjdb_{jd} is coordinate dd of vector bjb_j;
  • ∥v∥2\lVert v \rVert^2 is the squared length of a vector vv: the sum of its coordinates squared;
  • it is the usual distance of geometry, without the square root (which does not change which vectors are closest).

Example. D=2D = 2. a has the rows a0=(0,0)a_0 = (0, 0) and a1=(1,1)a_1 = (1, 1); b has the rows b0=(3,4)b_0 = (3, 4) and b1=(0,1)b_1 = (0, 1).

  • D00\mathrm{D}_{00}: a0−b0=(−3,−4)a_0 - b_0 = (-3, -4), and 9+16=259 + 16 = 25.
  • D01\mathrm{D}_{01}: a0−b1=(0,−1)a_0 - b_1 = (0, -1), and 0+1=10 + 1 = 1.
  • D10\mathrm{D}_{10}: a1−b0=(−2,−3)a_1 - b_0 = (-2, -3), and 4+9=134 + 9 = 13.
  • D11\mathrm{D}_{11}: a1−b1=(1,0)a_1 - b_1 = (1, 0), and 1+0=11 + 0 = 1.

The result is the 2×22 \times 2 table [[25, 1], [13, 1]].

Method 1: all the differences at once (too much memory)

Broadcasting can build every difference ai−bja_i - b_j in one expression: a[:, None, :] - b[None, :, :] has shape (N,M,D)(N, M, D), one difference vector for every pair. Squaring and summing over the last axis gives the answer.

This is a good way to check your answer on small inputs, but it stores N×M×DN \times M \times D numbers. For N=M=300N = M = 300 and D=128D = 128 that is 11.5 million numbers, 92 MB (megabytes), only to keep 90,000 of them; with real data it grows to gigabytes.

Method 2: expand the square (little memory)

The square of a difference can be expanded, just like (x−y)2=x2+y2−2xy(x - y)^2 = x^2 + y^2 - 2xy for numbers:

∥ai−bj∥2=∥ai∥2+∥bj∥2−2 ai⋅bj\lVert a_i - b_j \rVert^2 = \lVert a_i \rVert^2 + \lVert b_j \rVert^2 - 2\, a_i \cdot b_j

where ai⋅bj=∑daid bjda_i \cdot b_j = \sum_d a_{id}\, b_{jd} is the dot product: multiply coordinate by coordinate and add up. Each of the three parts is cheap:

  • ∥ai∥2\lVert a_i \rVert^2 for every ii: one number per row of a, a column of NN numbers;
  • ∥bj∥2\lVert b_j \rVert^2 for every jj: one number per row of b, a row of MM numbers;
  • ai⋅bja_i \cdot b_j for every pair: exactly the matrix product a @ b.T, an N×MN \times M table.

Example. Same vectors as above.

  • Squared lengths of a: ∥a0∥2=0\lVert a_0 \rVert^2 = 0, ∥a1∥2=1+1=2\lVert a_1 \rVert^2 = 1 + 1 = 2.
  • Squared lengths of b: ∥b0∥2=9+16=25\lVert b_0 \rVert^2 = 9 + 16 = 25, ∥b1∥2=0+1=1\lVert b_1 \rVert^2 = 0 + 1 = 1.
  • Dot products a @ b.T: [[0, 0], [7, 1]], for example a1⋅b0=1⋅3+1⋅4=7a_1 \cdot b_0 = 1 \cdot 3 + 1 \cdot 4 = 7.

Entry (1,0)(1, 0): 2+25−2⋅7=132 + 25 - 2 \cdot 7 = 13, the same as method 1. The whole table is again [[25, 1], [13, 1]], and nothing bigger than N×MN \times M was ever stored.

What to implement

pairwise_sq_dists(a, b): a has shape (N,D)(N, D), b has shape (M,D)(M, D). Return the (N,M)(N, M) table of squared distances.

Requirements

  • No Python loops.
  • Every distance is ≥0\geq 0, and the distance of a vector to itself is 00 (up to rounding).
  • Memory: one test uses N=M=300N = M = 300, D=128D = 128 and fails if memory use goes above 20 MB, so method 1 does not pass. Use method 2.

Hints

  1. Write method 1 first and keep it to check method 2 on small inputs.
  2. In method 2, the squared lengths of a must be a column, shape (N,1)(N, 1), and those of b a row, shape (1,M)(1, M). Added together, broadcasting turns them into the (N,M)(N, M) table of ∥ai∥2+∥bj∥2\lVert a_i \rVert^2 + \lVert b_j \rVert^2 (see Broadcasting, example 2, on the theory page).
  3. Rounding can make the result slightly negative, such as −10−15-10^{-15}, when aia_i and bjb_j are almost equal. Replace negative values with 0: np.maximum(d, 0.0).
solution.py
Python idle

Press Run (⌘/Ctrl + Enter) to test your code.