Vectors and the Dot Product
Context
A vector is an ordered list of numbers, and a listing's features written in a fixed order make one, each position standing for one feature. The dot product takes two vectors of the same length, multiplies them position by position and adds the products, and the single number it returns, divided by the two vectors' lengths (how far each one's point sits from the all-zero point, not how many entries it has), measures how closely they point the same way.
A linear price model prices a listing with one dot product, its weights against the listing's features plus a fixed amount, and Module 3 fits those weights from data. Finding the listings whose profile is most like the one a buyer is looking at compares directions with the same operation divided by two lengths. Every matrix product, starting with the next lesson's, is a table of dot products.
Vectors that disagree about the order of the features price a listing without an error or a warning, and the price comes out wrong by hundreds of thousands. Writing the element-wise product where the dot product was meant returns one product per feature instead of one number, and a cosine that rounding pushed a hair above 1 becomes nan the moment an inverse cosine (the function that turns a cosine back into its angle) receives it. Section 2.2 built the 1-D array and its element-wise arithmetic, and this lesson reads the same array as a point with a length and a direction.
Goal
- Build a listing's feature vector from a record in one fixed feature order, and keep that order across every vector in a calculation.
- Compute a dot product with
np.dot,@and(a * b).sum(), and read the error two vectors of different lengths raise. - Predict a listing's price as
w @ x + band apply one weight vector to several listings. - Compute a vector's length, the cosine of the angle between two vectors and the angle in degrees, and classify a pair from the sign of its dot product.
- Compare a float result with an exact value through
np.isclose, and clip a cosine before taking its inverse.
Prerequisites
None of this is required, the lesson builds everything it uses from Section 2.2's arrays. Both are here if you want the geometry pictured before you compute it.
- YouTube - "Vectors | Chapter 1, Essence of linear algebra" by 3Blue1Brown. A vector as an arrow and as a list of numbers at once, with adding and scaling drawn on a grid.
- Official docs - NumPy,
numpy.dot. The reference entry, whose first case is this lesson's: two 1-D arrays give their inner product, another name for the dot product.
Table of Contents
- Vectors as Arrays
- The Dot Product
- The Dot Product as a Prediction
- Length, Angle and Orthogonality
Theory
Nothing to install, every block runs on the numpy that Lesson 2.2.1 installed into (bootcamp).
Vectors as Arrays
What. A vector is an ordered list of numbers, and in NumPy it is a 1-D array. A listing's features in a fixed order, area_sqm, rooms and age, make one vector with one entry per feature, and geometrically the listing is a point in a space with one axis per feature.
Why. Every calculation in this section sees a listing only through its vector, and an array carries no feature names, so the position of each number is the only thing that says what it means.
When. Every time a record's numeric features enter a calculation: a prediction, a comparison between two listings, a change applied to one.
A listing of 80 square meters with 3 rooms, built 20 years ago, is np.array([80, 3, 20]), a vector with three entries. With two features you could draw it as a point on a grid, area along one axis and rooms along the other, and a third feature adds a third axis. A table of dozens of features has dozens of axes, which nobody draws, and every operation in this section works the same way at any count.
import numpy as np
x = np.array([80, 3, 20]) # area_sqm, rooms, age
print(x.shape) # (3,)
print(x[1]) # 3Adding two vectors and scaling one by a number are Lesson 2.2.3's element-wise arithmetic, read geometrically. Adding moves the point. Scaling slides it along the line through the origin, the all-zero point: outward for a factor above 1, inward for one between 0 and 1, and through to the opposite side for a negative one.
import numpy as np
x = np.array([80, 3, 20]) # area_sqm, rooms, age
extension = np.array([15, 1, 0]) # 15 sqm and one more room
print(x + extension) # [95 4 20]
print(2 * x) # [160 6 40]The order of the features is a contract, and every vector in one calculation must keep it. Listings rarely arrive as arrays. They arrive as records, and a dictionary's values come out in the order its keys were inserted (1.4.3), which is whatever order the source happened to write. Building the vector from one list of feature names fixes the order once, for every record.
import numpy as np
# the contract, written down once
FEATURES = ["area_sqm", "rooms", "age"]
# a record, keys in the order its source wrote them
listing = {"rooms": 3, "age": 20, "area_sqm": 80}
x = np.array([listing[name] for name in FEATURES])
x_raw = np.array(list(listing.values()))
extension = np.array([15, 1, 0])
print(x) # [80 3 20]
print(x_raw) # [ 3 20 80]
print(x + extension) # [95 4 20]
print(x_raw + extension) # [18 21 80]x_raw + extension describes a listing of 18 square meters with 21 rooms, built 80 years ago, and NumPy produced it without complaint, because nothing in an array says what its positions mean.
DON'T:
- DON'T build a vector with
list(record.values()), the values follow the record's own key order, not your feature list. - DON'T patch a vector built in the wrong order by editing its numbers, rebuild it from its record through
FEATURES, so one list of names decides every position.
The Dot Product
What. The dot product of two vectors of the same length multiplies matching entries and adds the products, a . b = a[0] * b[0] + a[1] * b[1] + ..., and returns one number. NumPy computes it as np.dot(a, b), as a @ b and, spelled out, as (a * b).sum().
Why. It folds many features into one number, a price, a score, a similarity, and the next lesson's matrix product is built from nothing else.
When. Whenever each entry of one vector should weigh the matching entry of another and only the total matters.
@ is Python's matrix-multiplication operator, an operator the language reserves for libraries such as NumPy to define, and between two 1-D arrays NumPy defines it as the dot product. The block pairs the listing with a second vector w, one number per feature in the same order, which the next subtitle reads as a price model's weights.
import numpy as np
x = np.array([80, 3, 20]) # area_sqm, rooms, age
w = np.array([2500, 10000, -1500]) # one number per feature, same order
print(w * x) # [200000 30000 -30000]
print((w * x).sum()) # 200000
print(np.dot(w, x)) # 200000
print(w @ x) # 200000
print(x @ w) # 200000
print(type(w @ x)) # <class 'numpy.int64'>Written out, 2500 * 80 + 10000 * 3 + (-1500) * 20 is 200000 + 30000 - 30000. Swapping the two vectors changes nothing, because each product pairs the same two entries either way. The result is a single NumPy number, an np.int64 here and an np.float64 when either vector holds float64 values, which print shows as a plain number and the interactive prompt echoes as np.int64(200000).
The two vectors must have the same length. A weight vector that leaves a feature out raises a ValueError before anything is multiplied.
# short_weights.py
import numpy as np
x = np.array([80, 3, 20]) # area_sqm, rooms, age
w_short = np.array([2500, 10000]) # the age weight left out
print(w_short @ x)Traceback (most recent call last):
File "/home/goku/dev/bootcamp/short_weights.py", line 6, in <module>
print(w_short @ x)
~~~~~~~~^~~
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 3 is different from 2)Operand 1 is the right-hand vector, counting from 0, and (size 3 is different from 2) sets its length against the 2 the left-hand vector brought. The signature in the middle is the shape rule behind every @, which Lesson 2.3.2 reads in full. np.dot(w_short, x) raises the same ValueError in other words, shapes (2,) and (3,) not aligned: 2 (dim 0) != 3 (dim 0).
DON'T:
- DON'T write
*where one number is wanted,w * xstops at the three products,[200000 30000 -30000], and the adding up is still to do. - DON'T expect a shorter vector to be padded or trimmed, a weight vector one feature short raises
ValueErrornaming both lengths, and that loud failure is the better kind, because the order mistake of the previous subtitle raises nothing.
The Dot Product as a Prediction
What. A linear model predicts a listing's price as the dot product of a weight vector with the listing's feature vector, plus one number called the bias: price = w . x + b, in NumPy w @ x + b.
Why. Each weight is the change in price per unit of its feature with the other features held fixed, so the model reads entry by entry, and the same few numbers price every listing.
When. Every time a linear model scores a listing. The weights here are given, and Module 3 fits them from data, in 3.1.
import numpy as np
x = np.array([80, 3, 20]) # area_sqm, rooms, age
w = np.array([2500, 10000, -1500]) # price per unit of each feature
b = 50000 # the bias
print(w @ x + b) # 250000The weights say 2500 per square meter, 10000 per room and minus 1500 per year of age. The bias is the price the model gives a listing whose features are all zero, a fixed amount inside every prediction. Moving one feature by one unit moves the price by that feature's weight.
import numpy as np
x = np.array([80, 3, 20])
w = np.array([2500, 10000, -1500])
b = 50000
base = w @ x + b
one_more_sqm = x + np.array([1, 0, 0])
ten_years_older = x + np.array([0, 0, 10])
print(w @ one_more_sqm + b - base) # 2500
print(w @ ten_years_older + b - base) # -15000The weights are the model. Learned once, they price every listing with the same line, as the loop below shows, and Lesson 2.3.2 replaces that loop with a single matrix product.
import numpy as np
w = np.array([2500, 10000, -1500]) # area_sqm, rooms, age
b = 50000
listings = [
np.array([80, 3, 20]),
np.array([120, 4, 5]),
np.array([55, 2, 40]),
]
for x in listings:
print(w @ x + b)250000
382500
147500The feature-order contract matters most here. Weights stored in a different order from the features still multiply and add, and the price comes out confidently wrong.
import numpy as np
x = np.array([80, 3, 20]) # area_sqm, rooms, age
w_swapped = np.array([10000, 2500, -1500]) # rooms, area_sqm, age
b = 50000
print(w_swapped @ x + b) # 827500The 80 square meters were priced as 80 rooms at 10000 each. Every multiplication was legal, so nothing raised, and the defense is building every vector, the weights included, from the same list of feature names.
DON'T:
- DON'T store the weights in a different order from the features, the dot product pairs entries by position and never checks what a position means.
- DON'T drop the bias,
w @ xalone is200000, the prediction minusb.
Length, Angle and Orthogonality
What. A vector's length is the square root of its dot product with itself, |v| = sqrt(v . v), in NumPy np.sqrt(v @ v), which Lesson 2.3.3 names the norm. The angle theta between two vectors comes from cos(theta) = u . v / (|u| |v|), and two vectors whose dot product is zero are orthogonal, the word for perpendicular.
Why. The sign of the dot product says whether two listings point the same way, and the cosine compares their directions with both lengths divided out, which is how similarity between listings is scored.
When. When two profiles are compared regardless of their size, when two directions are checked for having nothing in common, and whenever a computed float should land on an exact value.
These examples use standardized features, Lesson 2.2.5's mean 0 and standard deviation 1 per feature, so each entry says how many standard deviations a listing sits above the average (positive) or below it (negative), and a vector can point in any direction.
import numpy as np
u = np.array([2, 1, 2]) # standardized area_sqm, rooms, age
print(np.sqrt(u @ u)) # 3.0Dividing the dot product by both lengths leaves the cosine of the angle between the vectors. np.arccos, the inverse cosine, turns that cosine into its angle in radians (the unit in which a full turn is 2 * pi, about 6.28), and np.degrees converts radians to degrees.
import numpy as np
u = np.array([2, 1, 2])
v = np.array([2, 2, 1])
cos = (u @ v) / (np.sqrt(u @ u) * np.sqrt(v @ v))
print(cos) # 0.8888888888888888
print(np.arccos(cos)) # 0.47588224966041665
print(np.degrees(np.arccos(cos))) # 27.266044450732828The sign of the dot product sorts every pair into three cases, and the same listing u against three others shows all of them.
import numpy as np
u = np.array([2, 1, 2]) # above average on every feature
others = [
np.array([2, 2, 1]), # also above average on every feature
np.array([-1, 2, 0]), # less area, more rooms, average age
np.array([-2, -2, -1]), # below average on every feature
]
for v in others:
cos = (u @ v) / (np.sqrt(u @ u) * np.sqrt(v @ v))
print(u @ v, np.round(np.degrees(np.arccos(cos)), 1))8 27.3
0 90.0
-8 152.7Each product in a dot product is positive where the two entries sit on the same side of average and negative where they sit on opposite sides. A positive total means the agreements outweigh the disagreements and the angle is under 90 degrees. Zero means they cancel exactly and the vectors are orthogonal, at 90 degrees unless one of them is all zeros, which has no direction and so no angle. A negative total means the disagreements win and the angle is over 90 degrees. The size of the dot product grows with both lengths as well, so only its sign speaks about direction alone.
Used as a score, the cosine is called cosine similarity: 1 for the same direction, 0 for perpendicular, -1 for opposite directions. With both lengths divided out it compares direction alone, so a listing and one exactly twice its size in every feature should score 1.
import numpy as np
x = np.array([80, 3, 20]) # area_sqm, rooms, age
y = 2 * x # twice the area, rooms and age
cos = (x @ y) / (np.sqrt(x @ x) * np.sqrt(y @ y))
print(cos) # 1.0000000000000002
print(cos == 1.0) # False
print(np.isclose(cos, 1.0)) # TrueBoth square roots are stored rounded, Lesson 1.1.2's floats that cannot hold most values exactly, so the cosine lands one rounding step above 1 and == reports False for two vectors that point exactly the same way. np.isclose(a, b) returns True when the two differ by no more than a small tolerance, the comparison Module 1 asked for in place of == on floats. On float features the size of these, the orthogonality test is np.isclose(u @ v, 0.0) for the same reason, and Lesson 2.3.4 shows why much larger values need a test that scales with them.
An inverse cosine is defined only from -1 to 1, so that one rounding step is enough to break the angle.
# arccos_clip.py
import numpy as np
x = np.array([80, 3, 20])
y = 2 * x
cos = (x @ y) / (np.sqrt(x @ x) * np.sqrt(y @ y))
print(np.arccos(cos))
print(np.arccos(np.clip(cos, -1.0, 1.0)))/home/goku/dev/bootcamp/arccos_clip.py:8: RuntimeWarning: invalid value encountered in arccos
print(np.arccos(cos))
nan
0.0np.clip (2.2.3) pulls the cosine back inside the range the inverse cosine accepts, and the angle comes out as 0.0, the right answer for two vectors pointing the same way.
DON'T:
- DON'T compare a float result with an exact value using
==, usenp.isclose, andnp.isclose(u @ v, 0.0)to test float features the size of these for orthogonality. - DON'T hand a computed cosine straight to
np.arccos, clip it withnp.clip(cos, -1.0, 1.0)first. - DON'T call
len(v)a vector's length in this sense,len(np.array([3, 4]))is2, the number of entries, while the same vector'snp.sqrt(v @ v)is5.0. - DON'T take the cosine of an all-zero vector, such as a standardized listing sitting exactly at the average, it has no direction, and the division returns
nanwithRuntimeWarning: invalid value encountered in scalar divide.
Lab Exercises
Lab Exercise 1: Three Records in One Order, and One Number From Two Vectors
Three listing records arrive as dictionaries with their keys in different orders.
records = [
{"rooms": 4, "area_sqm": 120, "age": 5},
{"age": 40, "rooms": 2, "area_sqm": 55},
{"area_sqm": 95, "age": 12, "rooms": 3},
]Build each record into a vector in the order FEATURES = ["area_sqm", "rooms", "age"] and print the three vectors. Then print what np.array(list(records[1].values())) builds, with a comment saying which feature landed in which slot.
With the weights w = np.array([2500, 10000, -1500]), compute the dot product of w with the first listing's vector three ways and print all three. Print w * x for the same listing, with a comment saying what it is instead. Finally, compute w[:2] @ x inside a try, catch the ValueError and print its message.
Lab Exercise 2: Price Three Listings, Then Compare Profiles by Angle
Using the three vectors from Lab Exercise 1, the same w and the bias b = 50000, print each listing's predicted price from one loop. Then show with two predictions that one more room changes the third listing's price by the rooms weight.
Next, take the standardized profile p = np.array([1, 2, 2]) and compare it with [2, 2, 0], [2, 1, -2] and [0, -1, -1]. For each, print the dot product, the cosine rounded to 3 places, the angle in degrees rounded to 1 place, and a label, same way, perpendicular or apart, chosen from the sign of the dot product.
Finally, compute the cosine between the first listing's vector and three times that vector, and print it, its comparison with 1.0 by == and its comparison by np.isclose.
Solution 1: Three Records in One Order, and One Number From Two Vectors
import numpy as np
FEATURES = ["area_sqm", "rooms", "age"]
records = [
{"rooms": 4, "area_sqm": 120, "age": 5},
{"age": 40, "rooms": 2, "area_sqm": 55},
{"area_sqm": 95, "age": 12, "rooms": 3},
]
# one comprehension per record, names in FEATURES order
vectors = [np.array([r[n] for n in FEATURES]) for r in records]
for v in vectors:
print(v)
# the age, 40, lands in the area slot and the area, 55, in the age slot
print(np.array(list(records[1].values())))[120 4 5]
[55 2 40]
[95 3 12]
[40 2 55]import numpy as np
w = np.array([2500, 10000, -1500]) # area_sqm, rooms, age
x = np.array([120, 4, 5]) # the first listing
print(np.dot(w, x)) # 332500
print(w @ x) # 332500
print((w * x).sum()) # 332500
# the three terms of the dot product, not yet added
print(w * x) # [300000 40000 -7500]
try:
print(w[:2] @ x) # two weights against three features
except ValueError as err:
print(err)matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 3 is different from 2)DON'T:
- DON'T catch the
ValueErrorand carry on with a default, a weight vector of the wrong length means the model and the data disagree about the features, and the run should stop. - DON'T read
w * xas the dot product, it is the dot product's three terms with the adding left undone.
Solution 2: Price Three Listings, Then Compare Profiles by Angle
import numpy as np
w = np.array([2500, 10000, -1500]) # area_sqm, rooms, age
b = 50000
vectors = [np.array([120, 4, 5]), np.array([55, 2, 40]), np.array([95, 3, 12])]
for x in vectors:
print(w @ x + b)
x = vectors[2]
one_more_room = x + np.array([0, 1, 0])
print(w @ one_more_room + b - (w @ x + b))382500
147500
299500
10000import numpy as np
p = np.array([1, 2, 2])
for q in (np.array([2, 2, 0]), np.array([2, 1, -2]), np.array([0, -1, -1])):
dot = p @ q
cos = dot / (np.sqrt(p @ p) * np.sqrt(q @ q))
angle = np.degrees(np.arccos(np.clip(cos, -1.0, 1.0)))
if dot > 0:
label = "same way"
elif dot == 0: # integers, so == is exact here
label = "perpendicular"
else:
label = "apart"
print(dot, np.round(cos, 3), np.round(angle, 1), label)6 0.707 45.0 same way
0 0.0 90.0 perpendicular
-4 -0.943 160.5 apartimport numpy as np
x = np.array([120, 4, 5])
y = 3 * x
cos = (x @ y) / (np.sqrt(x @ x) * np.sqrt(y @ y))
print(cos) # 1.0000000000000002
print(cos == 1.0) # False
print(np.isclose(cos, 1.0)) # TrueDON'T:
- DON'T label a pair with
== 0on a float dot product, it is exact here only because the profiles are whole numbers, and on float features of this size the test isnp.isclose(dot, 0.0). - DON'T drop the clip because the cosines in front of you happen to be safe, the first listing against its triple produced
1.0000000000000002, whichnp.arccosturns intonan.
Theory Assessment
Q1: The Products, the Sum and the Missing Weight
For w = [2500, 10000, -1500] and x = [80, 3, 20], explain step by step what w @ x computes, and why w * x is not the same kind of result. Then say what happens when w has two entries and x three, and why that outcome is better than a weight vector that has all three entries but in the wrong order.
Hint: Count how many numbers each expression returns. For the second half, ask which of the two mistakes produces a number at all.
Q2: What a Weight Says
A price model has w = [2500, 10000, -1500] for [area_sqm, rooms, age] and b = 50000. Say what each weight and the bias mean, by how much the predicted price moves when a listing ages 10 years, and why the same w and b serve a 55 square meter listing and a 120 square meter one. Then a teammate builds listing vectors as [rooms, area_sqm, age] and leaves w as it is. What does the model predict for the listing with 80 square meters, 3 rooms and age 20?
Hint: A weight is a rate. For the teammate's vector, write out the three products the dot product forms.
Q3: Signs, Angles and a Pair That Should Score 1
Three pairs of standardized listings have dot products 8, 0 and -8. Say what each sign tells you about the angle between the pair and about how their features compare. Then explain why a listing and its exact double should have cosine similarity 1, why the lesson's run printed 1.0000000000000002, and what that value does to np.arccos.
Hint: A dot product is a sum of one product per feature, and a product is positive when both entries sit on the same side of average. For the second half, ask which steps of the cosine produce a number a float cannot hold exactly.
Q4: The Most Similar Listing
A teammate ranks candidate listings by their dot product with a query listing and calls the top one the most similar. Read the following without running it, then state what each line prints.
import numpy as np
query = np.array([2, 1, 2]) # standardized area_sqm, rooms, age
candidates = [
np.array([2, 1, 2]),
np.array([4, 4, 1]),
np.array([1, 0, 1]),
]
for c in candidates:
cos = (query @ c) / (np.sqrt(query @ query) * np.sqrt(c @ c))
print(query @ c, np.round(cos, 3), cos == 1.0)Which candidate does the teammate's rule pick, which does cosine similarity pick, and why do the two rankings disagree? Then explain why the first line's last value is True, when the lesson's cosine of a listing and its double compared False.
Hint: The dot product grows with both lengths. For the last part, work out every number the first candidate's cosine is built from, and ask which of them a float stores exactly.
Answer 1:
w @ x multiplies matching entries, 2500 * 80 = 200000, 10000 * 3 = 30000 and -1500 * 20 = -30000, then adds the three products, 200000. It returns one number. w * x stops after the multiplying and returns the three products as an array, [200000 30000 -30000], the dot product's terms rather than its value, and (w * x).sum() is the missing step.
With two weights and three features, w @ x raises ValueError before anything is multiplied, and the message names the two lengths, (size 3 is different from 2). That is the better outcome, because it stops the script on the line with the bug. Weights in the wrong order have the right length, every multiplication is legal, and the result is a number that is simply wrong, 827500 for a listing the model prices at 250000.
DON'T treat a result's shape as a detail, one number is a dot product and three numbers are not.
Answer 2:
Each weight is a rate, the change in predicted price for one unit of its feature with the other features held fixed: 2500 per square meter, 10000 per room and minus 1500 per year of age. The bias is what the model predicts for a listing whose features are all zero, a fixed amount inside every prediction. Ten more years move the price by 10 * -1500, which is -15000. One w and one b price every listing because those four numbers are the whole model, and only x changes from listing to listing.
The teammate's vector for that listing is [3, 80, 20], so the dot product forms 2500 * 3 + 10000 * 80 - 1500 * 20, and with the bias the prediction is 827500. That is exactly what the swapped weights produced, because swapping the same two positions in either vector pairs the same numbers. Nothing raises in either case.
DON'T fix an order bug by reordering one side without checking the other, the pairing of positions is the only thing the dot product sees.
Answer 3:
A dot product of 8 is positive, so the agreements in sign outweigh the disagreements and the angle is under 90 degrees, 27.3 in the lesson's run. A dot product of 0 means they cancel exactly, the angle is 90 degrees and the pair is orthogonal. A dot product of -8 is negative, the disagreements win and the angle is over 90 degrees, 152.7 in the run.
A listing and its double point the same way, because doubling stretches a vector along its own direction, so the cosine is 1 in exact arithmetic. The run computes two square roots that a float cannot store exactly, then multiplies and divides rounded values, and the result lands one rounding step above 1, 1.0000000000000002. np.arccos is defined only from -1 to 1, so that value returns nan with a RuntimeWarning, and np.clip(cos, -1.0, 1.0) restores the angle 0.0.
DON'T read a nan from np.arccos as a fault in the vectors before checking whether the cosine strayed past 1.
Answer 4:
9 1.0 True
14 0.812 False
4 0.943 FalseThe teammate's rule picks the second candidate, whose dot product, 14, is the largest. Cosine similarity picks the first, identical to the query with a cosine of 1.0, and ranks the third above the second although the third has the smallest dot product. The rankings disagree because a dot product mixes length with direction: the second candidate sits far from average, its length is np.sqrt(33), about 5.74, and that length inflates its dot product while its direction is the furthest of the three from the query's. The third is short and points close to the query. Which listing has the most similar profile is a question about direction, so the cosine answers it and the raw dot product does not.
The first line compares True because nothing in that cosine rounds. The dot product is 9, both squared lengths are 9, np.sqrt(9) is exactly 3.0, and 9 / (3.0 * 3.0) is exactly 1.0, every value one a float holds exactly. The lesson's pair took square roots of numbers that are not perfect squares, and those were stored rounded. == happened to work here, code that relies on it can fail on the next pair, as the listing and its double did, so np.isclose stays the comparison.
DON'T rank similarity by raw dot product, a long vector outranks a well-aligned one, and divide by both lengths first.
Assignment
Assignment: What Moves the Price, and What Leaves It Alone
A pricing analyst is reviewing the model with weights [2500, 10000, -1500] for [area_sqm, rooms, age] and bias 50000, on a listing that arrived as the record {"age": 20, "area_sqm": 80, "rooms": 3}. Build the following in one script, building the listing's vector from the record in FEATURES order.
First, a what-if table: the change in this listing's predicted price when one feature alone moves by 1, 5 or 10 units, as a 3 by 3 array with one row per step size and one column per feature. A teammate's version, np.array([1, 5, 10]) * w, runs without an error. Print what it returns and explain in a comment what each of its three numbers is and why nothing warned you. Then build the table with np.outer, which takes two 1-D arrays a and b and returns the 2-D array whose row i, column j holds a[i] * b[j], so np.outer(a, b) has the shape (len(a), len(b)). Check the cell for five more rooms against two full predictions.
Second, find a renovation the model cannot see: a change vector of whole numbers that alters at least two features of the listing yet leaves its predicted price exactly where it was. Prove it twice, with the dot product of the change and the weights, and with two full predictions. Print the angle between the change vector and the weight vector, find a second such change that alters a different pair of features, and say in a comment what every change the model ignores has in common.
Hints:
np.outer(a, b)[i, j]isa[i] * b[j], so the array whose entries should run down the rows goes first.- Adding a change
deltatoxmoves the prediction byw @ delta, because the bias cancels andw @ (x + delta)isw @ x + w @ delta. Look for two features whose weights trade off exactly.
Solution:
import numpy as np
FEATURES = ["area_sqm", "rooms", "age"]
record = {"age": 20, "area_sqm": 80, "rooms": 3}
x = np.array([record[name] for name in FEATURES])
w = np.array([2500, 10000, -1500])
b = 50000
base = w @ x + b
steps = np.array([1, 5, 10])
print(x) # [80 3 20]
print(base) # 250000
print(steps * w) # [ 2500 50000 -15000]
# Step 1 paired with area, step 5 with rooms and step 10 with age, three
# of the table's nine cells, one from each row. Nothing warned, because
# 'steps' and 'w' have the same length, 3, and '*' pairs by position.
table = np.outer(steps, w)
print(table)
five_more_rooms = x + np.array([0, 5, 0])
print(w @ five_more_rooms + b - base) # 50000
print(table[1, 1]) # 50000[[ 2500 10000 -1500]
[ 12500 50000 -7500]
[ 25000 100000 -15000]]# continuing the script above
delta = np.array([4, -1, 0]) # 4 sqm more, one room fewer
print(w @ delta) # 0
print(w @ (x + delta) + b - base) # 0
cos = (w @ delta) / (np.sqrt(w @ w) * np.sqrt(delta @ delta))
print(np.degrees(np.arccos(cos))) # 90.0
delta_2 = np.array([3, 0, 5]) # 3 sqm more, 5 years older
print(w @ delta_2) # 0
print(w @ (x + delta_2) + b - base) # 0
# Every change the model ignores is orthogonal to the weight vector.
# The price moves by w @ delta, so a change at 90 degrees to w is
# invisible to the model, whatever it does to the listing.The teammate's three numbers are the diagonal of the table, the cells where the row's step and the column's feature happen to share a position. With four step sizes the same line raises the broadcasting ValueError, and with exactly three it returned a plausible array, which is the lesson's order warning in a new place.
np.outer multiplies every entry of steps by every entry of w, so row i is steps[i] * w, the whole weight vector scaled by one step size. The dot product and the outer product are opposites in what they keep: the dot product multiplies only matching entries and adds everything into one number, while the outer product multiplies every pair and adds nothing.
The renovations that leave the price alone are the deeper result. A change of 4 square meters is worth 4 * 2500, exactly one room at 10000, so gaining the area and losing the room cancel. Three more square meters, 7500, cancel five more years of age, 5 * -1500. Both changes have a dot product of zero with w, so both sit at 90 degrees to it, and the price, which moves by w @ delta, cannot see either. Every listing the model prices at 250000 is x plus some change orthogonal to w.
DON'T let two same-length arrays meet in * when you meant every pair, element-wise pairing gives no error whenever the lengths happen to match, and don't read an unchanged prediction as an unchanged listing, the change may simply be orthogonal to the weights.
Additional Resources
Optional, and nothing later depends on either. Worth opening if you want the geometry behind the formula or the exact rule behind np.isclose.
- YouTube - "Dot products and duality | Chapter 9, Essence of linear algebra" by 3Blue1Brown. Why multiplying matching entries and adding them up agrees with the geometric picture of one vector projected onto another.
- Official docs - NumPy,
numpy.isclose. The inequalitynp.isclosetests, its default tolerancesrtol=1e-05andatol=1e-08, and the warning that the absolute one is too loose for numbers much smaller than one.
Summary
The array from Section 2.2 did not change in this lesson, only the way it is read. A row of three numbers stopped being a container and became a point with a position, a length and a direction, and that one shift turns a handful of element-wise habits into geometry. Adding a vector moves a listing, scaling one stretches it, and the question of how alike two listings are becomes a question about an angle, which a few lines of NumPy answer.
The dot product is the hinge. Multiplying matching entries and adding them up is about the plainest arithmetic there is, and it turns out to be both the way a linear model prices a listing and the way two profiles are compared. The weights of a price model are not mysterious: each is a rate, read entry by entry, and the prediction is a sum of rates times amounts plus the bias. The same sum, divided by two lengths, measures direction, and its sign alone sorts every pair into agreeing, unrelated and opposed.
What the operation does not do matters as much as what it does. It checks that the two lengths agree and trusts you for everything else, so the meaning of every position lives outside the array, in a list of feature names that is written down once and obeyed everywhere. Its answers are exact in arithmetic and rounded in memory, so a result that should land on 1 or on 0 is judged with a tolerance rather than with equality. The next lesson stacks many vectors into one table and runs every dot product at once, and both disciplines come along unchanged.
Cheat Sheet
Part A - Reference Table
| Concept | Formula | NumPy | Note |
|---|---|---|---|
| Vector | features in a fixed order | np.array([80, 3, 20]) |
the order is a contract |
| Vector from a record | one value per feature name | np.array([rec[n] for n in FEATURES]) |
never list(rec.values()) |
| Dot product | a . b, the sum of a[i] * b[i] |
a @ b, np.dot(a, b), (a * b).sum() |
one number, lengths must match |
| Element-wise product | one product per entry | a * b |
not a dot product |
| Prediction | w . x + b |
w @ x + b |
a weight is price per unit |
| Length | sqrt(v . v) |
np.sqrt(v @ v) |
the norm, Lesson 2.3.3 |
| Cosine | dot product over both lengths | (u @ v) / (np.sqrt(u @ u) * np.sqrt(v @ v)) |
1 same way, 0 perpendicular, -1 opposite |
| Angle | arccos(cos) |
np.degrees(np.arccos(np.clip(cos, -1.0, 1.0))) |
clip first |
| Orthogonal | u . v = 0 |
np.isclose(u @ v, 0.0) |
float features of moderate size, never == |
| Outer product | a[i] * b[j] for every pair |
np.outer(a, b) |
shape (len(a), len(b)) |
Part B - Compact Code Block
import numpy as np
FEATURES = ["area_sqm", "rooms", "age"]
x = np.array([rec[n] for n in FEATURES]) # fixed feature order
w = np.array([2500, 10000, -1500]) # price per unit
price = w @ x + 50000 # dot product plus bias
w * x # three products, not one
u, v = np.array([2, 1, 2]), np.array([2, 2, 1])
np.sqrt(u @ u) # length, 3.0
cos = (u @ v) / (np.sqrt(u @ u) * np.sqrt(v @ v))
angle = np.degrees(np.arccos(np.clip(cos, -1.0, 1.0)))
np.isclose(cos, 1.0) # never == on floats
np.outer([1, 5, 10], w) # every pair multiplied