Math, Statistics, and Data › Linear Algebra I: Vectors and Matrices › Day 103
Hands-on lab — Day 103: Dot Products and Similarity
- ← Back to the Day 103 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/math-statistics-and-data/day-103-dot-products-and-similarity/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-103-dot-products-and-similarity
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)" Run
cd examples && ../.venv/bin/python3 01_the_length_confound.py && cd ..
cd examples && ../.venv/bin/python3 02_dot_product_and_sign.py && cd ..
cd examples && ../.venv/bin/python3 03_from_scratch_vs_numpy.py && cd ..
cd examples && ../.venv/bin/python3 04_same_ranking_on_the_sphere.py && cd ..
cd examples && ../.venv/bin/python3 05_not_a_metric.py && cd ..
cd examples && ../.venv/bin/python3 06_semantic_search.py && cd ..
cd examples && ../.venv/bin/python3 07_curse_of_dimensionality.py && cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider Test
bash tests/run_tests.sh File tree
examples/01_the_length_confound.py examples/02_dot_product_and_sign.py examples/03_from_scratch_vs_numpy.py examples/04_same_ranking_on_the_sphere.py examples/05_not_a_metric.py examples/06_semantic_search.py examples/07_curse_of_dimensionality.py examples/catalogue.py examples/conftest.py examples/similarity.py examples/test_reference.py expected-output/01-the-length-confound.txt expected-output/02-dot-product-and-sign.txt expected-output/03-from-scratch-vs-numpy.txt expected-output/04-same-ranking-on-the-sphere.txt expected-output/05-not-a-metric.txt expected-output/06-semantic-search.txt expected-output/07-curse-of-dimensionality.txt expected-output/FIELDS.md expected-output/reference-tests.txt expected-output/starter-progress.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/answers.py starter/conftest.py starter/similarity.py starter/test_starter.py tests/run_tests.sh troubleshooting.md
Lab README
Day 103 lab — Which Question Are You Asking?
Lesson
- Lesson title: Dot Products and Similarity
- Day number: 103 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-103-dot-products-and-similarity
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-103-dot-products-and-similaritywhen the site is running.
Purpose
Day 99 gave you Euclidean distance and it worked well enough that it was easy to miss what it was actually measuring. This lab breaks it on purpose, in one number.
Take the roast-chicken article and write it again at twice the length — every count doubled, same subject, same emphasis. Measure the distance from the original to its own doubled copy: 9.0554. Now measure the distance from the original to race-day-nutrition, an article that is mostly about running: 8.0623. Euclidean distance says an article's own doubled copy is further away than a genuinely different article is.
Nothing is wrong with the arithmetic. The question was wrong. "How far apart are these two points" is not the same question as "are these two things about the same subject", and for text they have different answers.
So you build the measure that asks the right question. Seven functions in pure Python — dot product, norm, normalise, Euclidean distance, cosine similarity, cosine distance, and a ranking — checked against NumPy on every pair in the catalogue. Then you use them to prove five things, each of which is asserted rather than asserted-at:
- Cosine similarity between an article and its doubled copy is exactly 1.0.
- The sign of the dot product tells you the angle: positive under 90 degrees, zero at exactly 90, negative above.
- On normalised vectors, ranking by cosine and ranking by Euclidean distance produce the identical order — which is why a vector database normalises on the way in and then uses whichever is faster.
- Cosine distance is not a metric: it fails the triangle inequality, on a concrete triple of two-dimensional vectors you can check on paper.
- As the number of dimensions grows, random vectors become nearly orthogonal and distances bunch up — measured, with a seeded generator, from dimension 2 to dimension 8192.
And in between, you write a working semantic search over the six articles and assert its top result for two queries. It is four lines. That is not a simplification for teaching; that is the retrieval step of a real system, and everything a production one adds is about getting better vectors and searching more of them faster.
Learning objectives
By the end of this lab you can:
- Reproduce, from real numbers, the case where Euclidean distance calls an
article's own doubled copy further away than a different article — and
explain why the distance between
vand2vis exactly|v|. - Implement
dot,l2_norm,normalise,euclidean_distance,cosine_similarityandcosine_distancefrom first principles in pure Python, and assert each against NumPy to a stated tolerance. - State what
a · b = |a| |b| cos θmeans geometrically, compute the scalar and vector projection of one vector onto another, and explain why the projection is not symmetric even though the dot product is. - Read the sign of a dot product as an angle: positive, zero, negative.
- Explain why cosine similarity is magnitude-free by construction, and demonstrate it by scaling either vector and watching the answer not move.
- Derive
|u - v| = sqrt(2 - 2 cos θ)for unit vectors, and use it to explain why the two measures rank identically on normalised vectors and can disagree on raw ones. - Produce a triple where cosine distance fails the triangle inequality, and say what breaks in a search index when a "distance" is not a metric.
- Build a working semantic search from a cosine ranking, and defend the tie-breaking rule that makes it deterministic.
- Measure the curse of dimensionality — falling mean absolute cosine, and concentrating distances — with a seeded generator, and say what it changes about reading a similarity score.
- Say when Euclidean distance is the right choice, and give an example where magnitude carries meaning.
Prerequisites
- Day 99 — vectors, components, the L2 norm, unit vectors, normalisation and Euclidean distance. This lab uses the identical six-article catalogue.
- Day 101 — the dot product computed mechanically. Today it gets its geometric meaning.
- Day 70 — floating point, which is why every comparison here declares a
tolerance and why
cosine_similarityclamps. - Days 71 to 74 — pytest, used for both the reference suite and the running score.
- Day 43 —
python3 -m venvand installing a package withpip. - No mathematics beyond school arithmetic. Every number in this lab can be re-derived with a pen.
Supported operating systems
- macOS — the authoring machine, macOS 26.5.2 on Apple Silicon (arm64). Everything in this README was run there.
- Linux — the same commands, unchanged. Not run here, so it is stated as an expectation rather than a result.
- Windows — the Python is identical; the shell is not.
tests/run_tests.shis a bash script and needs bash, which Windows Subsystem for Linux and Git Bash both provide. Inside WSL the instructions are the Linux instructions. Paths differ (.venv\Scripts\python.exerather than.venv/bin/python3). None of this was tested here.
Hardware requirements
Anything that runs Python. The largest thing this lab allocates is a pair of
2000-by-8192 float arrays in section 7 — about 250 MB peak — and it frees them
between dimensions. If that is too much on your machine, reduce PAIRS or drop
the last entry of DIMENSIONS in
examples/07_curse_of_dimensionality.py; the shape of the result does not
depend on either. No GPU. No internet after the one install.
Required software
| Tool | Version used here | Purpose |
|---|---|---|
| Python | 3.14.0 | Everything. |
| numpy | 2.5.2 | The independent check on your arithmetic, and the seeded generator. |
| pytest | 9.1.1 | The reference suite and your running score. |
| bash | 3.2.57 | tests/run_tests.sh. |
Those are the versions this lab was actually run on. Nothing else was tested, so nothing else is claimed.
Free and open-source options
Every tool here is free and open source, and there is no paid tier of anything involved.
| Tool | Licence | Cost | Account needed |
|---|---|---|---|
| Python | PSF licence | Free | No |
| numpy | BSD 3-Clause | Free | No |
| pytest | MIT | Free | No |
| bash | GPL | Free | No |
One package is described but not installed: scipy.spatial.distance, which
provides cosine, euclidean and about twenty other distance functions and is
what you would reach for in real work. It is not in requirements.txt, it was
not run here, and this lab reproduces no output from it. SciPy is BSD-licensed
and free; installing it is one line if you want to compare, but then the
comparison is yours, not this lab's.
Installation
From this directory:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)"
Expect 2.5.2. That install is the only time this lab touches the network.
File structure
day-103-dot-products-and-similarity/
├── README.md this file
├── metadata.yml how the lab was run, and what it printed
├── security.md what the lab does to your machine, and what embeddings expose
├── troubleshooting.md every symptom hit while building this lab
├── requirements/
│ ├── README.md why each package is here, and why numpy is pinned
│ └── requirements.txt numpy==2.5.2, pytest==9.1.1
├── starter/ YOUR WORK
│ ├── 00_brief.md read this first
│ ├── similarity.py seven functions to write
│ ├── answers.py 24 predictions to make
│ ├── test_starter.py your running score
│ └── conftest.py the import guard — do not delete
├── examples/ the reference implementation
│ ├── catalogue.py the Day 99 articles, unchanged, plus the queries
│ ├── similarity.py the answer key for starter/similarity.py
│ ├── 01_the_length_confound.py the failure the day exists to fix
│ ├── 02_dot_product_and_sign.py the geometric meaning, projection, and the sign
│ ├── 03_from_scratch_vs_numpy.py every pair checked against NumPy, and three edges
│ ├── 04_same_ranking_on_the_sphere.py the identity, and the rankings matching
│ ├── 05_not_a_metric.py the triangle inequality failing
│ ├── 06_semantic_search.py the four-line search
│ ├── 07_curse_of_dimensionality.py the measurement
│ ├── test_reference.py 76 tests over every claim above
│ └── conftest.py the import guard — do not delete
├── expected-output/ captured from real runs, never fabricated
│ ├── 01-the-length-confound.txt … 07-curse-of-dimensionality.txt
│ ├── reference-tests.txt
│ ├── starter-progress.txt
│ ├── test-run.txt
│ └── FIELDS.md what may differ on your machine, and what may not
└── tests/
└── run_tests.sh the harness: 49 checks
How to run
Read starter/00_brief.md, then work through starter/similarity.py and
starter/answers.py, checking yourself as you go:
.venv/bin/pytest starter -q
On an untouched checkout that reports 1 passed, 51 skipped. Anything you have
not written is skipped, not failed. When everything is written it reports
52 passed.
Then read the reference, in order, from inside examples/:
cd examples
../.venv/bin/python3 01_the_length_confound.py
../.venv/bin/python3 02_dot_product_and_sign.py
../.venv/bin/python3 03_from_scratch_vs_numpy.py
../.venv/bin/python3 04_same_ranking_on_the_sphere.py
../.venv/bin/python3 05_not_a_metric.py
../.venv/bin/python3 06_semantic_search.py
../.venv/bin/python3 07_curse_of_dimensionality.py
cd ..
Then the whole thing:
bash tests/run_tests.sh
What the commands do
| Command | What it does |
|---|---|
python3 -m venv .venv |
Creates a virtual environment inside the lab, so nothing here can affect the rest of your machine. |
.venv/bin/pip install -r requirements/requirements.txt |
Installs numpy 2.5.2 and pytest 9.1.1. The only network access in the lab. |
.venv/bin/pytest starter -q |
Your running score. Unwritten exercises are skipped; wrong answers fail with both numbers printed. |
.venv/bin/pytest examples -q |
The 76-test reference suite, asserting every claim the lesson makes. |
01_the_length_confound.py |
Reproduces the failure: 9.0554 against 8.0623, then cosine returning exactly 1.0. |
02_dot_product_and_sign.py |
`a · b = |
03_from_scratch_vs_numpy.py |
Fifteen pairs checked against NumPy, three equivalent routes to the same cosine, and three edges: the zero vector, rounding past 1.0, and mismatched lengths. |
04_same_ranking_on_the_sphere.py |
Derives and checks |u - v| = sqrt(2 - 2 cos θ), then ranks the catalogue both ways and shows the orders identical — and shows them differing on raw vectors. |
05_not_a_metric.py |
The triangle inequality failing for cosine distance and holding for Euclidean, on the same triple. |
06_semantic_search.py |
The four-line search, two queries, and a demonstration that the query's own length changes nothing. |
07_curse_of_dimensionality.py |
Mean absolute cosine and distance concentration from dimension 2 to 8192, seeded. |
bash tests/run_tests.sh |
49 checks over all of the above, including one that deliberately breaks the harness to prove it can fail. |
Expected output
Every file in expected-output/ was captured from a real run on the authoring
machine on 2026-08-16. The three numbers the day rests on, from
01-the-length-confound.txt:
roast-chicken vs its own doubled copy
[9, 0, 1, 0] - [18, 0, 2, 0] = [-9, 0, -1, 0]
squares: 81 + 0 + 1 + 0 = 82
sqrt(82) = 9.0554
roast-chicken vs race-day-nutrition
[9, 0, 1, 0] - [4, 6, 3, 0] = [5, -6, -2, 0]
squares: 25 + 36 + 4 + 0 = 65
sqrt(65) = 8.0623
cos(roast-chicken, its doubled copy) = 1.0000000000
cos(roast-chicken, race-day-nutrition) = 0.5514330137
The ranking equivalence, from 04-same-ranking-on-the-sphere.txt:
rank by cosine (high first) sim by distance (low first) dist
---------------------------------------------------------------------------------
1 roast-chicken 1.000000 roast-chicken 0.000000
2 slow-cooker-stew 0.990992 slow-cooker-stew 0.134220
3 race-day-nutrition 0.551433 race-day-nutrition 0.947172
4 household-budget 0.219512 household-budget 1.249390
5 marathon-plan 0.011908 marathon-plan 1.405768
6 storm-bulletin 0.000000 storm-bulletin 1.414214
the two orders are identical : True
The metric failure, from 05-not-a-metric.txt:
going the long way round : d(a, b) + d(b, c) = 0.292893 + 0.292893 = 0.585786
going direct : d(a, c) = 1.000000
is direct <= long way? : False
The search, from 06-semantic-search.txt:
"roast it"
1. roast-chicken 0.9939
2. slow-cooker-stew 0.9701
3. race-day-nutrition 0.5121
"training for a race and what to eat"
1. race-day-nutrition 0.9035
2. marathon-plan 0.9011
3. roast-chicken 0.3691
The curse, from 07-curse-of-dimensionality.txt:
dimension mean |cos| exact sqrt(2/(pi d)) max |cos| mean angle sd of angle within 10 deg
------------------------------------------------------------------------------------------------------
2 0.6435 0.6366 0.5642 1.0000 88.65 52.23 11.1%
3 0.5015 0.5000 0.4607 0.9997 90.20 39.07 16.9%
8 0.2891 0.2910 0.2821 0.9107 89.73 21.55 36.0%
32 0.1400 0.1422 0.1410 0.5664 90.41 10.10 67.1%
128 0.0712 0.0707 0.0705 0.3214 89.98 5.09 95.1%
512 0.0351 0.0353 0.0353 0.1625 90.04 2.52 100.0%
2048 0.0179 0.0176 0.0176 0.0856 90.00 1.29 100.0%
8192 0.0089 0.0088 0.0088 0.0394 90.00 0.64 100.0%
And the final line of the harness:
49 checks, 0 failure(s).
expected-output/FIELDS.md lists exactly which parts of these files may
legitimately differ on your machine and which may not.
Validation steps
.venv/bin/python3 -c "import numpy; print(numpy.__version__)"prints2.5.2..venv/bin/pytest examples -qreports76 passed..venv/bin/pytest starter -qreports1 passed, 51 skippedbefore you start, and52 passedwhen you have finished.- Each of the seven scripts in
examples/ends with the line<name>.py: every assertion held.and exits 0. bash tests/run_tests.shends with49 checks, 0 failure(s).and exits 0. Check the exit status directly, not through a pipeline:bash tests/run_tests.sh; echo "exit=$?"- Section 6 of that run reports that a deliberately wrong expectation makes the harness exit non-zero. If section 6 passes, section 5 is not decorative.
git statusshows nothing untracked in the lab beyond.venv/and your own edits tostarter/.
Tests
tests/run_tests.sh runs 49 checks in seven sections:
| Section | What it proves |
|---|---|
| 1 | The installed numpy matches the pin, and is version 2 or later. |
| 2 | All seven reference scripts exit 0 and report every internal assertion holding. |
| 3 | The reference pytest suite passes, with at least 70 tests collected. |
| 4 | The starter suite skips unattempted work rather than failing it — and the skip count is unchanged when both suites are collected together, which is the check that proves the import guard still works. |
| 5 | Twenty of the lesson's claims, each read as a real value from a real run: the two distances, the cosine of 1.0, agreement with NumPy across every pair, the three sign cases, both ranking results, the unit-sphere identity, the triangle-inequality failure, both search results, the scale invariance, the zero-vector refusal, the clamp, and the dimensionality measurement. |
| 6 | The harness can fail. It re-runs itself with the ranking-equivalence expectation inverted and asserts that the re-run exits non-zero and reports exactly one failure. |
| 7 | Nothing was left behind: no __pycache__, no .pytest_cache, and no source file that opens a network connection. |
Assertions are on shapes and values, never on timings. Nothing in this lab asserts a millisecond figure, because such a test is flaky on someone else's machine.
Cleanup
find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv # optional: removes the lab virtual environment
git checkout -- starter/ # optional: resets your work
The harness leaves nothing behind on its own — section 7 checks — but a script
you ran by hand without PYTHONDONTWRITEBYTECODE=1 will leave __pycache__
directories.
Troubleshooting
troubleshooting.md covers every symptom hit while building this lab,
including the two that cost the most time:
ValueError: math domain errorfrommath.acos— yourcosine_similarityis not clamping. Three of the six articles miss exact 1.0 when compared with themselves, andrace-day-nutritioncomes out at1.0000000000000002, whichacosrefuses. This is measured here, not hypothetical.pytest starterreporting passes for exercises you have not written — theconftest.pyimport guard is missing or edited. Both directories contain a module calledsimilarity, and without the guard a combined run measures the reference solution.
Security notes
Full detail in security.md. In short: this lab computes and prints, opens no
network connection after the one pip install, needs no credentials and no
sudo, writes nothing outside its own directory, and cleans up after itself.
Two points that are specific to today and are worth carrying beyond it. First,
an embedding is a fingerprint of the document it came from — a vector derived
from a person's text is derived from a person's text, and similarity search
over it can link documents back to their author whether or not a name was ever
stored. Second, the seeded generator in section 7 is reproducible by design
and must never be used where unpredictability is the requirement; for tokens,
passwords, keys and nonces, use secrets.
Extension exercises
- Make it fail the other way. Construct two vectors where cosine similarity is high and the two documents are obviously about different things. Hint: cosine reads direction only, so a very short document about one thing and a very long one about the same thing plus much else can score deceptively well. What does that tell you about using cosine alone for relevance?
- Soft cosine. Plain cosine treats every feature as unrelated to every
other, so "cooking" and "baking" would be orthogonal even though they are
nearly synonyms. Add a 4-by-4 feature-similarity matrix and compute
q^T S d / sqrt(q^T S q · d^T S d). Check it reduces to plain cosine whenSis the identity — that check is the point of the exercise. - Measure the normalise-once saving. Time a thousand cosine similarities computed from raw vectors against a thousand dot products of pre-normalised ones. Report the ratio, on your machine, and say what the number would have been if you had asserted a millisecond figure instead.
- Break the index. Write a tiny pruning search that uses the triangle inequality to skip candidates, run it with Euclidean distance and then with cosine distance on the same data, and find a query where the cosine version returns the wrong answer. That is the practical cost of "not a metric".
- Push the curse further. Extend the dimensionality table down to dimension 1 and up as far as your memory allows. At dimension 1 the mean absolute cosine is exactly 1 — every pair is either parallel or opposite. Check that the exact formula predicts it.
- Negative components. The six articles are counts, so nothing is negative and no pair can be more than 90 degrees apart. Generate vectors with negative components, confirm that cosine distances above 1 now appear, and say what a negative similarity would mean if these were real embeddings.
Navigation
- Previous day: Day 102 — Linear Transformations
- Next day: Day 104 — NumPy: Arrays and Vectorized Thinking
- Week 15: Linear Algebra I: Vectors and Matrices
- Section: Mathematics, Statistics and Data
Expected output
01-the-length-confound.txt
The same article, written twice as long
article cooking running money weather |v|
----------------------------------------------------------------------------
roast-chicken 9 0 1 0 9.0554
roast-chicken (2x length) 18 0 2 0 18.1108
Every count doubled. The writer said the same things, twice each.
Euclidean distance, worked out in full
roast-chicken vs its own doubled copy
[9, 0, 1, 0] - [18, 0, 2, 0] = [-9, 0, -1, 0]
squares: 81 + 0 + 1 + 0 = 82
sqrt(82) = 9.0554
roast-chicken vs race-day-nutrition
[9, 0, 1, 0] - [4, 6, 3, 0] = [5, -6, -2, 0]
squares: 25 + 36 + 4 + 0 = 65
sqrt(65) = 8.0623
So Euclidean distance says the doubled copy of roast-chicken
(9.0554) is FURTHER from roast-chicken than an article
about race-day nutrition is (8.0623).
There is a tidy reason the first number came out the way it did.
Doubling a vector v gives 2v, and the difference is 2v - v = v, so
the distance between an article and its doubled copy is exactly the
article's own length: |v| = 9.0554. Longer articles are
punished harder, which is the opposite of what a search should do.
Cosine similarity, on exactly the same numbers
cos(roast-chicken, its doubled copy) = 1.0000000000
cos(roast-chicken, race-day-nutrition) = 0.5514330137
1.0 means the angle between them is zero: the two vectors point
in exactly the same direction. They are the same article, and the
measure says so.
Why it is exactly 1 rather than nearly 1: cosine similarity
divides by both lengths, so scaling either vector by a positive
number multiplies the top and the bottom by the same factor and
cancels. Here is that cancellation with the real numbers:
dot = 9*18 + 0*0 + 1*2 + 0*0 = 164
|v| = 9.055385
|2v| = 18.110770 (exactly twice 9.055385)
164 / (9.055385 * 18.110770) = 1.0000000000
The same thing said with unit vectors. Normalise both — divide each
by its own length — and they land on the identical point:
unit(roast-chicken) = [0.993884, 0.000000, 0.110432, 0.000000]
unit(doubled copy) = [0.993884, 0.000000, 0.110432, 0.000000]
distance between them = 0.0000000000
Both measures against roast-chicken, whole catalogue, doubled copy included
article Euclidean cosine agrees?
----------------------------------------------------------
roast-chicken 0.0000 1.0000 True
slow-cooker-stew 1.4142 0.9910 False
marathon-plan 12.8841 0.0119 False
race-day-nutrition 8.0623 0.5514 False
household-budget 11.3137 0.2195 True
storm-bulletin 12.8062 0.0000 False
roast-chicken (2x) 9.0554 1.0000 False
ranked by Euclidean : roast-chicken, slow-cooker-stew, race-day-nutrition, roast-chicken (2x), household-budget, storm-bulletin, marathon-plan
ranked by cosine : roast-chicken, roast-chicken (2x), slow-cooker-stew, race-day-nutrition, household-budget, marathon-plan, storm-bulletin
The two rankings disagree on RAW counts, and they disagree in the
place that matters: cosine puts the doubled copy joint first,
Euclidean puts it fourth. Section 4 shows that once every vector is
normalised the two measures agree completely — the disagreement is
entirely about magnitude.
01_the_length_confound.py: every assertion held.
02-dot-product-and-sign.txt
The algebraic definition and the geometric one are the same number
a = [3, 4] |a| = 5.0000 (a 3-4-5 triangle, so exactly 5)
b = [10, 0] |b| = 10.0000
Algebraic: multiply component by component, then add.
3*10 + 4*0 = 30 + 0 = 30
Geometric: |a| |b| cos(theta).
theta = 53.1301 degrees, cos(theta) = 0.6000
5 * 10 * 0.6000 = 30.0000
Same number, reached two ways. The algebraic route is what a
computer runs; the geometric route is what it means.
The projection picture: how much of b lies along a
Shine a light straight down onto a's direction. b casts a shadow.
length of the shadow = (a dot b) / |a| = 30 / 5 = 6.0000
the shadow as a vector = [3.6000, 4.8000]
its length = 6.0000
Check it the other way: |b| cos(theta) = 10 * 0.6000 = 6.0000
The projection is NOT symmetric. Projecting a onto b instead:
(b dot a) / |b| = 30 / 10 = 3.0000
The dot product does not care about order — 30 either
way — but the shadow does, because you have chosen a different
surface to cast it on.
And the special case that ties Day 99 to today: project a onto
itself and the shadow is the whole vector.
(a dot a) / |a| = 25 / 5 = 5.0000 = |a|
so |a| = sqrt(a dot a) = sqrt(25) = 5.0000
What the sign tells you, one worked example of each
case a b a.b cos angle sign
-------------------------------------------------------------------------------
same direction [3, 0] [6, 0] 18 1.0000 0.00 positive
45 degrees apart [3, 0] [1, 1] 3 0.7071 45.00 positive
perpendicular [3, 0] [0, 5] 0 0.0000 90.00 zero
135 degrees apart [3, 0] [-2, 2] -6 -0.7071 135.00 negative
opposite direction [3, 0] [-6, 0] -18 -1.0000 180.00 negative
Read the middle two columns together and the rule falls out:
dot > 0 <-> cos > 0 <-> angle under 90 degrees (agreeing)
dot = 0 <-> cos = 0 <-> angle exactly 90 degrees (unrelated)
dot < 0 <-> cos < 0 <-> angle over 90 degrees (opposing)
The sign of the dot product and the sign of the cosine are always
the same, because the two lengths you divide by are never negative.
So if all you need is the DIRECTION of the relationship, the raw
dot product answers it and you can skip both square roots.
Orthogonality is not an abstraction: three pairs in the catalogue have it
roast-chicken . storm-bulletin = 0
9*0 + 0*1 + 1*0 + 0*9 = 0
cosine similarity 0.0000, angle 90.00 degrees
slow-cooker-stew . storm-bulletin = 0
8*0 + 0*1 + 2*0 + 0*9 = 0
cosine similarity 0.0000, angle 90.00 degrees
household-budget . storm-bulletin = 0
1*0 + 0*1 + 9*0 + 0*9 = 0
cosine similarity 0.0000, angle 90.00 degrees
Every product in that sum is zero because wherever one article has
a count the other has none. They share no vocabulary at all, and
orthogonal is exactly what that means: not opposed, just entirely
unrelated. Nothing you learn about one tells you anything about
the other.
02_dot_product_and_sign.py: every assertion held.
03-from-scratch-vs-numpy.txt
Every pair in the catalogue, mine against NumPy's
pair dot mine numpy difference
--------------------------------------------------------------------------------------
roast-chicken / slow-cooker-stew 74 0.990992 0.990992 0.00e+00
roast-chicken / marathon-plan 1 0.011908 0.011908 0.00e+00
roast-chicken / race-day-nutrition 39 0.551433 0.551433 0.00e+00
roast-chicken / household-budget 18 0.219512 0.219512 0.00e+00
roast-chicken / storm-bulletin 0 0.000000 0.000000 0.00e+00
slow-cooker-stew / marathon-plan 2 0.026153 0.026153 0.00e+00
slow-cooker-stew / race-day-nutrition 38 0.590017 0.590017 0.00e+00
slow-cooker-stew / household-budget 26 0.348187 0.348187 0.00e+00
slow-cooker-stew / storm-bulletin 0 0.000000 0.000000 0.00e+00
marathon-plan / race-day-nutrition 57 0.786975 0.786975 0.00e+00
marathon-plan / household-budget 9 0.107173 0.107173 0.00e+00
marathon-plan / storm-bulletin 27 0.321520 0.321520 0.00e+00
race-day-nutrition / household-budget 31 0.438319 0.438319 0.00e+00
race-day-nutrition / storm-bulletin 6 0.084836 0.084836 0.00e+00
household-budget / storm-bulletin 0 0.000000 0.000000 0.00e+00
15 pairs, largest disagreement 0.00e+00, tolerance 1e-12
Note that this is agreement, not identity. The two implementations
add the products up in the same order here, so they happen to
produce bit-identical answers, but nothing guarantees that in
general — NumPy is free to reorder a summation for speed, and
floating-point addition is not associative (Day 70). Compare with
a tolerance, always.
Three routes to the same cosine similarity
divide the dot product by both lengths : 0.551433013749212
dot product of the two UNIT vectors : 0.551433013749212
the same, through numpy.dot : 0.551433013749212
The second route is the one that matters in practice. Normalise
every vector ONCE when you store it, and every later comparison is
a bare dot product with no square roots in it at all. That is what
a vector index does, and it is why 'dot product' and 'cosine' are
offered as separate options by systems that store embeddings: on
already-normalised vectors they are the same thing, and the dot
product is cheaper.
Three places where the naive formula needs care
1. The zero vector. It has no direction, so the angle to it does
not exist, and the formula divides by zero.
written out in NumPy without a guard : nan
this lab's version : ValueError: cosine similarity is undefined when either vector is the zero vector: it has no direction to compare
A NaN sorts unpredictably and spreads through every average it
touches. Raise instead. An empty document is a real thing that
happens, and it should stop the pipeline, not poison it.
2. Rounding past 1. Compare a vector with ITSELF and the answer
should be exactly 1.0. Here is what it actually is, unclamped,
for all six articles — plain integer counts, nothing exotic:
article unclamped (a dot a) / (|a| |a|)
---------------------------------------------------------
roast-chicken 0.9999999999999998
slow-cooker-stew 1.0
marathon-plan 1.0
race-day-nutrition 1.0000000000000002
household-budget 0.9999999999999998
storm-bulletin 0.9999999999999998
exactly 1.0 : 2 of 6
just under : 3 of 6
just over : 1 of 6
The one that rounds UP is race-day-nutrition, at 1.0000000000000002,
and that single bit of rounding is fatal downstream:
math.acos(1.0000000000000002) -> ValueError: expected a number in range from -1 up to 1, got 1.0000000000000002
clamped, this lab gives 1.0
and an angle of 0.0 degrees
Half a dozen four-component integer vectors were enough to
produce this, which is the point: it is not an exotic case you
will meet once. Any code that turns a similarity into an angle,
or asserts a score is at most 1.0, clamps first. This lab
clamps, and every reported similarity below is a clamped one.
3. Mismatched lengths. Two vectors of different sizes cannot be
compared, and the failure should be loud.
this lab's version : ValueError: vectors must have the same number of components: got 3 and 2
NumPy : ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 2 is different from 3)
Both raise ValueError, which is deliberate: one except clause
catches either implementation.
Cosine distance is just 1 minus the similarity
pair similarity distance
----------------------------------------------------------------
roast-chicken / roast-chicken 1.000000 0.000000
roast-chicken / slow-cooker-stew 0.990992 0.009008
roast-chicken / race-day-nutrition 0.551433 0.448567
roast-chicken / storm-bulletin 0.000000 1.000000
[1, 0] / [-1, 0] (opposite directions) -1.000000 2.000000
The range is 0 to 2, not 0 to 1 — because similarity runs from 1
down to -1. On count vectors, where nothing is ever negative, no
pair can be more than 90 degrees apart, so the distance never
exceeds 1 and 'orthogonal' is as far apart as two articles get.
Embeddings from a trained model DO have negative components, and
there the upper half of the range is reachable.
03_from_scratch_vs_numpy.py: every assertion held.
04-same-ranking-on-the-sphere.txt
The identity, checked on every pair in the catalogue
pair cos sqrt(2-2cos) |u - v| gap
----------------------------------------------------------------------------------------
roast-chicken / slow-cooker-stew 0.990992 0.134220 0.134220 6.11e-16
roast-chicken / marathon-plan 0.011908 1.405768 1.405768 2.22e-16
roast-chicken / race-day-nutrition 0.551433 0.947172 0.947172 0.00e+00
roast-chicken / household-budget 0.219512 1.249390 1.249390 2.22e-16
roast-chicken / storm-bulletin 0.000000 1.414214 1.414214 2.22e-16
slow-cooker-stew / marathon-plan 0.026153 1.395598 1.395598 0.00e+00
slow-cooker-stew / race-day-nutrition 0.590017 0.905520 0.905520 1.11e-16
slow-cooker-stew / household-budget 0.348187 1.141765 1.141765 2.22e-16
slow-cooker-stew / storm-bulletin 0.000000 1.414214 1.414214 2.22e-16
marathon-plan / race-day-nutrition 0.786975 0.652726 0.652726 0.00e+00
marathon-plan / household-budget 0.107173 1.336283 1.336283 2.22e-16
marathon-plan / storm-bulletin 0.321520 1.164887 1.164887 0.00e+00
race-day-nutrition / household-budget 0.438319 1.059888 1.059888 0.00e+00
race-day-nutrition / storm-bulletin 0.084836 1.352896 1.352896 0.00e+00
household-budget / storm-bulletin 0.000000 1.414214 1.414214 2.22e-16
Largest gap between the formula and the measurement: 6.11e-16
Rank the catalogue both ways, from the same query, after normalising
rank by cosine (high first) sim by distance (low first) dist
---------------------------------------------------------------------------------
1 roast-chicken 1.000000 roast-chicken 0.000000
2 slow-cooker-stew 0.990992 slow-cooker-stew 0.134220
3 race-day-nutrition 0.551433 race-day-nutrition 0.947172
4 household-budget 0.219512 household-budget 1.249390
5 marathon-plan 0.011908 marathon-plan 1.405768
6 storm-bulletin 0.000000 storm-bulletin 1.414214
the two orders are identical : True
Now the same comparison WITHOUT normalising, to show what the
normalisation was doing. The doubled copy of roast-chicken is
added so the length difference is real:
by cosine : roast-chicken, roast-chicken (2x), slow-cooker-stew, race-day-nutrition, household-budget, marathon-plan, storm-bulletin
by distance : roast-chicken, slow-cooker-stew, race-day-nutrition, roast-chicken (2x), household-budget, storm-bulletin, marathon-plan
identical? : False
So the claim is precise and worth stating precisely: the two
measures agree on NORMALISED vectors and can disagree on raw ones.
Normalising is not a tidying step you do out of habit. It is the
step that makes the two measures interchangeable, and if you skip
it, which one you picked changes the answers.
The curve behind the claim, sampled
cosine distance on the unit sphere angle
------------------------------------------------
1.0 0.000000 0.0
0.9 0.447214 25.8
0.5 1.000000 60.0
0.0 1.414214 90.0
-0.5 1.732051 120.0
-0.9 1.949359 154.2
-1.0 2.000000 180.0
Every step down in cosine is a step up in distance, with no
exceptions and no flat stretches. Two unit vectors pointing the
same way are 0 apart; perpendicular ones are sqrt(2) = 1.414214
apart; opposite ones are 2 apart, which is the diameter of the
sphere and the furthest two unit vectors can get.
What this does NOT say: the two measures produce the same SCORES.
They do not, and a threshold tuned for one is meaningless for the
other. A cut-off of 'similarity above 0.9' is 'distance below
0.447214', and nothing about the second number is
guessable from the first. Only the ORDER is preserved.
04_same_ranking_on_the_sphere.py: every assertion held.
05-not-a-metric.txt
Three vectors, chosen so every number is exact on paper
a = [1, 0] pointing straight along the first axis
b = [1, 1] the bisector, 45 degrees from each
c = [0, 1] pointing straight along the second axis
Their cosine distances
d(a, b) = 1 - cos = 1 - 0.707107 = 0.292893 (angle 45.0 degrees)
d(b, c) = 1 - cos = 1 - 0.707107 = 0.292893 (angle 45.0 degrees)
d(a, c) = 1 - cos = 1 - 0.000000 = 1.000000 (angle 90.0 degrees)
a to b is 1 - 1/sqrt(2) = 1 - 0.707107 = 0.292893, and so is b to c.
The triangle inequality, tested
going the long way round : d(a, b) + d(b, c) = 0.292893 + 0.292893 = 0.585786
going direct : d(a, c) = 1.000000
is direct <= long way? : False
The direct route is longer than the detour, by 0.414214.
That is not a rounding artefact and it is not a bug in the code.
It is what 'not a metric' means, in one line of arithmetic.
The same three points under Euclidean distance, which IS a metric
On the raw vectors:
d(a, b) + d(b, c) = 1.000000 + 1.000000 = 2.000000
d(a, c) = 1.414214
holds? = True
And on the normalised ones, which is the case that matters,
because section 4 said to normalise and then use Euclidean:
d(a, b) + d(b, c) = 0.765367 + 0.765367 = 1.530734
d(a, c) = 1.414214
holds? = True
The angle itself, on the same triple
angle(a, b) + angle(b, c) = 45.0 + 45.0 = 90.0 degrees
angle(a, c) = 90.0 degrees
holds, with equality? = True
It holds here, and it holds with equality, because b sits exactly
on the shortest path from a to c — the three vectors are in one
plane and b is the halfway point. That is the degenerate case of a
triangle, a straight line, where the inequality becomes equality.
Being careful about what this does and does not show: one triple
where a rule holds is not a proof that it always holds. The angle
between two vectors is arc length on the unit sphere, and great-
circle distance on a sphere is known to be a metric — but that
proof is not in this lab, and this run does not supply it. What
this run does supply is the counter-example above, and one
counter-example IS a proof, of the negative: cosine distance is
not a metric, demonstrated, not asserted.
The other condition it fails, and why you wanted it to
d([9, 0, 1, 0], [18, 0, 2, 0]) = 0.000000
are the two vectors equal? False
A metric must have d(a, b) = 0 only when a and b are the same
point. Cosine distance gives 0 for every pair on the same ray from
the origin. That is a failure of the definition and the entire
reason the measure is useful for text: 'same direction' and 'same
vector' are different claims, and for documents you want the first
one. The measure is not broken. It is answering the question you
asked, and that question was never 'are these the same point'.
05_not_a_metric.py: every assertion held.
06-semantic-search.txt
The index: six articles, four features, and their unit vectors
article cooking running money weather |v| unit vector
-------------------------------------------------------------------------------
roast-chicken 9 0 1 0 9.0554 [0.994, 0.000, 0.110, 0.000]
slow-cooker-stew 8 0 2 0 8.2462 [0.970, 0.000, 0.243, 0.000]
marathon-plan 0 9 1 2 9.2736 [0.000, 0.970, 0.108, 0.216]
race-day-nutrition 4 6 3 0 7.8102 [0.512, 0.768, 0.384, 0.000]
household-budget 1 0 9 0 9.0554 [0.110, 0.000, 0.994, 0.000]
storm-bulletin 0 1 0 9 9.0554 [0.000, 0.110, 0.000, 0.994]
Normalising once, on the way in, is what a vector store does. After
this every query is a dot product and there is not a square root
left in the hot path.
Query: "roast it" -> [1, 0, 0, 0]
rank article a.b cosine angle euclid (raw)
---------------------------------------------------------------------
1 roast-chicken 9 0.993884 6.34 8.0623
2 slow-cooker-stew 8 0.970143 14.04 7.2801
3 race-day-nutrition 4 0.512148 59.19 7.3485
4 household-budget 1 0.110432 83.66 9.0000
5 marathon-plan 0 0.000000 90.00 9.3274
6 storm-bulletin 0 0.000000 90.00 9.1104
top result : roast-chicken at 0.993884
runner-up : slow-cooker-stew at 0.970143
margin : 0.023741
For contrast, the same query ranked by RAW Euclidean distance:
slow-cooker-stew, race-day-nutrition, roast-chicken, household-budget, storm-bulletin, marathon-plan
nearest by distance: slow-cooker-stew at 7.2801
which is NOT the cosine winner. Raw distance is still being
dominated by how long each article is.
And by the RAW dot product, with no division at all:
roast-chicken, slow-cooker-stew, race-day-nutrition, household-budget, marathon-plan, storm-bulletin
highest dot product: roast-chicken at 9
which agrees with cosine here, but only by luck of these
particular lengths.
Query: "training for a race and what to eat" -> [2, 5, 0, 0]
rank article a.b cosine angle euclid (raw)
---------------------------------------------------------------------
1 race-day-nutrition 38 0.903482 25.38 3.7417
2 marathon-plan 45 0.901082 25.70 5.0000
3 roast-chicken 18 0.369119 68.34 8.6603
4 slow-cooker-stew 16 0.360302 68.88 8.0623
5 storm-bulletin 5 0.102533 84.11 10.0499
6 household-budget 2 0.041013 87.65 10.3441
top result : race-day-nutrition at 0.903482
runner-up : marathon-plan at 0.901082
margin : 0.002400
For contrast, the same query ranked by RAW Euclidean distance:
race-day-nutrition, marathon-plan, slow-cooker-stew, roast-chicken, storm-bulletin, household-budget
nearest by distance: race-day-nutrition at 3.7417
which agrees with cosine here — agreement is possible, it is
just not guaranteed while the vectors have different lengths.
And by the RAW dot product, with no division at all:
marathon-plan, race-day-nutrition, roast-chicken, slow-cooker-stew, storm-bulletin, household-budget
highest dot product: marathon-plan at 45
also NOT the cosine winner. The dot product rewards long
articles, because a longer vector has more of everything to
multiply. Dot product and cosine are the same ranking only
after the vectors are normalised.
The four-line search, used the way it would be used
"roast it"
1. roast-chicken 0.9939
2. slow-cooker-stew 0.9701
3. race-day-nutrition 0.5121
"training for a race and what to eat"
1. race-day-nutrition 0.9035
2. marathon-plan 0.9011
3. roast-chicken 0.3691
The query's own length is irrelevant, which is worth proving
query roast-chicken slow-cooker-stew
----------------------------------------------------------
[1, 0, 0, 0] 0.9938837347 0.9701425001
[3, 0, 0, 0] 0.9938837347 0.9701425001
[100, 0, 0, 0] 0.9938837347 0.9701425001
Identical to ten decimal places. A one-word query and the same
word repeated a hundred times rank the catalogue exactly the same,
because the only thing cosine reads is the direction. Under raw
Euclidean distance those three queries give three different
answers, and Day 99 showed the shortest of them picking the wrong
article.
06_semantic_search.py: every assertion held.
07-curse-of-dimensionality.txt
Mean |cosine| between 2000 random vector pairs, by dimension
(numpy.random.default_rng(103), standard normal components)
dimension mean |cos| exact sqrt(2/(pi d)) max |cos| mean angle sd of angle within 10 deg
------------------------------------------------------------------------------------------------------
2 0.6435 0.6366 0.5642 1.0000 88.65 52.23 11.1%
3 0.5015 0.5000 0.4607 0.9997 90.20 39.07 16.9%
8 0.2891 0.2910 0.2821 0.9107 89.73 21.55 36.0%
32 0.1400 0.1422 0.1410 0.5664 90.41 10.10 67.1%
128 0.0712 0.0707 0.0705 0.3214 89.98 5.09 95.1%
512 0.0351 0.0353 0.0353 0.1625 90.04 2.52 100.0%
2048 0.0179 0.0176 0.0176 0.0856 90.00 1.29 100.0%
8192 0.0089 0.0088 0.0088 0.0394 90.00 0.64 100.0%
From dimension 2 to dimension 8192, the mean absolute cosine
fell from 0.6435 to 0.0089 — a factor of 72.
The fraction of pairs within 10 degrees of a right angle rose from
11.1% to 100.0%.
Two predictions sit beside the measurement, and the difference
between them is worth a paragraph. The 'exact' column is
gamma(d/2) / (sqrt(pi) gamma((d+1)/2)), the true mean of |cos| for
two independent random directions. The last column is the
approximation sqrt(2 / (pi d)), which is the one usually quoted.
The measurement matches the exact value to within 1.5% at every
dimension, on 2000 pairs. The approximation is off by up to 11%
at the small dimensions — it is a large-d limit, and at d = 2 and
d = 3 it simply is not the right number. Two hand-checkable cases
settle which column to trust: in 2 dimensions the angle is uniform
around the circle so the mean of |cos| is 2/pi = 0.63662, and in 3
dimensions the cosine itself is uniform from -1 to 1 so the mean is
exactly 0.5. The exact column gives both; the approximation gives
neither; the measurement agrees with the exact column. This is a
small thing, and it is the habit that matters: when a run and a
quoted formula disagree, find out which one is answering your
question before assuming the run is wrong.
None of that changes the headline, which is the first column
falling towards zero as the dimension grows.
The second half of the curse: distances bunch up
dimension nearest furthest ratio spread / mean
------------------------------------------------------------
2 0.0588 3.7420 63.6828 0.5163
3 0.1907 3.9152 20.5335 0.4063
8 1.5292 6.4500 4.2180 0.2248
32 5.6248 11.3036 2.0096 0.1093
128 12.8950 17.9126 1.3891 0.0567
512 28.9518 34.3982 1.1881 0.0274
2048 62.1604 67.4723 1.0855 0.0133
8192 124.1068 130.1466 1.0487 0.0073
In 2 dimensions the furthest of 500 random points
is 63.7 times as far as the nearest. In 8192 dimensions it is 1.05 times as far.
'Nearest neighbour' still has an answer, but the answer stops being
meaningfully nearer than everything else, and small errors in the
vectors start deciding the winner.
What this changes about reading a similarity score
A cosine similarity of 0.3 is a strong signal in 1000 dimensions
and unremarkable in 2, because in 1000 dimensions two unrelated
things score near 0 and almost nothing lands at 0.3 by accident.
The table above is the calibration: in this run at dimension 512
the mean |cos| between unrelated pairs was already under 0.04.
Three practical consequences, in order of how often they bite:
1. Never read a raw similarity score without knowing the
dimension. 'Above 0.8 means relevant' is a claim about one
model in one space, not a general fact.
2. Calibrate against your own data. Score a few hundred pairs you
know are unrelated, look at the distribution, and set the
threshold from that rather than from a number in a blog post.
3. Expect exact nearest-neighbour search to stop paying for
itself as the dimension grows. When the nearest point is barely
nearer than the tenth nearest, an approximate index that is
usually right is a good trade — which is what real vector
databases do, and why they are called approximate.
07_curse_of_dimensionality.py: every assertion held.
FIELDS.md
# What in the captured output may legitimately differ on your machine
Every file in this directory was captured from a real run on the authoring
machine on 2026-08-16, offline, with numpy 2.5.2 and pytest 9.1.1 on
CPython 3.14.0, macOS 26.5.2 (Apple Silicon, arm64). If your run differs in one
of the ways listed here, nothing is wrong. If it differs in any other way,
something is.
## Will differ, and does not matter
| What | Where | Why |
| --- | --- | --- |
| Elapsed times, such as `76 passed in 0.34s` | `reference-tests.txt`, `starter-progress.txt`, `test-run.txt` | Wall-clock timing. Nothing in this lab asserts on a duration, deliberately: a test that asserts milliseconds is flaky on a slower machine. |
| The `platform` line, for example `macOS-26.5.2-arm64-arm-64bit-Mach-O` | `test-run.txt` section 1 | It reports your operating system, release and processor architecture. Linux prints something quite different, and that is expected. |
| The `python` and `pytest` version lines | `test-run.txt` section 1 | Only CPython 3.14.0 and pytest 9.1.1 were run here, so those are the only versions this lab can honestly claim. |
| The exact text of NumPy's `ValueError` for mismatched shapes | `03-from-scratch-vs-numpy.txt` | The captured message names `matmul` and quotes a gufunc signature. NumPy has reworded this message across versions. The *type* is what the tests assert; the wording is quoted only to show you what you will actually see. |
| The pass/skip glyph line, such as `.ssssss...` | `starter-progress.txt` | Its length tracks the number of collected tests. The counted summary underneath is the part to compare. |
| Your own progress score | `starter-progress.txt` | The captured file shows an untouched checkout: `1 passed, 51 skipped`. As you complete exercises, passes replace skips, up to `52 passed`. That is the file changing because you changed, not because anything broke. |
## Will differ if your NumPy version differs
| What | Where | Why |
| --- | --- | --- |
| Every measured number in the dimensionality tables | `07-curse-of-dimensionality.txt`, and the `curse_values` check in `test-run.txt` section 5 | They come from `numpy.random.default_rng(103)`. NumPy guarantees the same stream for the same seed within a major version, and does not promise it across one. On a different NumPy the digits move. |
What must **not** change even then is the shape of the result: mean absolute
cosine falling at every step up in dimension, tracking the exact formula
`gamma(d/2) / (sqrt(pi) gamma((d+1)/2))` to within a few percent, and the
nearest-to-furthest distance ratio collapsing towards 1. The reference suite
asserts that shape rather than the digits, for exactly this reason. Section 5
of the harness does check four literal values, because on the pinned version
they are fixed and a silent change of generator would otherwise go unnoticed.
## Must NOT differ
| What | Where | Why it is fixed |
| --- | --- | --- |
| Every similarity, distance, angle, dot product and ranking over the six articles | `01-*.txt` through `06-*.txt` | They are computed from twenty-four small integers with no randomness, no clock and no file system involved. A different number means different arithmetic. |
| `9.0554`, `8.0623`, `1.0000000000` | `01-the-length-confound.txt` | The three numbers the whole day rests on: the distance to the doubled copy, the distance to a genuinely different article, and the cosine similarity that ignores both. |
| `0.585786` against `1.000000` | `05-not-a-metric.txt` | The triangle-inequality failure. Exact: `2 - sqrt(2)` against `1`. |
| `roast-chicken 0.993884` and `race-day-nutrition 0.903482` | `06-semantic-search.txt` | The two asserted search results. |
| The three floating-point values `0.9999999999999998`, `1.0` and `1.0000000000000002` | `03-from-scratch-vs-numpy.txt` | These are IEEE 754 double arithmetic on small integers, and are the same on any conforming platform. If yours differ, your floating point is not IEEE 754 doubles, which is worth knowing. |
| `76 passed` | `reference-tests.txt` | The reference suite has seventy-six tests. A different count means tests failed to collect. |
| `49 checks, 0 failure(s).` | `test-run.txt` | The harness runs a fixed number of checks. |
| The numpy version line `numpy 2.5.2` | `test-run.txt` section 1 | Pinned in `requirements/requirements.txt`, and section 1 compares the installed version against that file rather than trusting it. |
## Why section 6 of `test-run.txt` looks strange
It re-runs the whole harness with one expectation deliberately swapped for a
wrong one, and asserts that the re-run fails. The captured file therefore
contains a passing suite that proves it is capable of failing. That is
intentional: a green test suite proves nothing until you have watched it go
red.
## Reproducing these files
From the lab directory, after the one-time install:
```bash
cd examples
../.venv/bin/python3 01_the_length_confound.py
../.venv/bin/python3 02_dot_product_and_sign.py
../.venv/bin/python3 03_from_scratch_vs_numpy.py
../.venv/bin/python3 04_same_ranking_on_the_sphere.py
../.venv/bin/python3 05_not_a_metric.py
../.venv/bin/python3 06_semantic_search.py
../.venv/bin/python3 07_curse_of_dimensionality.py
cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider
bash tests/run_tests.sh
```
The scripts in `examples/` are run from inside `examples/` because they import
`similarity.py` and `catalogue.py` from beside themselves.
reference-tests.txt
........................................................................ [ 94%]
.... [100%]
76 passed in 0.34s
starter-progress.txt
.sssssssssssssssssssssssssssssssssssssssssssssssssss [100%]
1 passed, 51 skipped in 0.06s
test-run.txt
Day 103 — Which Question Are You Asking?
1. The tools and the versions this lab was written against
python 3.14.0
numpy 2.5.2
pytest 9.1.1
platform macOS-26.5.2-arm64-arm-64bit-Mach-O
exe python3
ok: installed numpy matches requirements.txt
ok: numpy is version 2 or later (numpy.random.default_rng and the 2.x repr)
2. Every reference script runs and every assertion inside it holds
ok: 01_the_length_confound.py exits 0
ok: 01_the_length_confound.py reports every assertion held
ok: 02_dot_product_and_sign.py exits 0
ok: 02_dot_product_and_sign.py reports every assertion held
ok: 03_from_scratch_vs_numpy.py exits 0
ok: 03_from_scratch_vs_numpy.py reports every assertion held
ok: 04_same_ranking_on_the_sphere.py exits 0
ok: 04_same_ranking_on_the_sphere.py reports every assertion held
ok: 05_not_a_metric.py exits 0
ok: 05_not_a_metric.py reports every assertion held
ok: 06_semantic_search.py exits 0
ok: 06_semantic_search.py reports every assertion held
ok: 07_curse_of_dimensionality.py exits 0
ok: 07_curse_of_dimensionality.py reports every assertion held
3. The reference pytest suite: real values, real rankings
........................................................................ [ 94%]
.... [100%]
76 passed in 0.34s
ok: pytest examples exits 0
ok: no test in the reference suite failed
ok: the reference suite ran at least 70 tests (ran 76)
4. The starter suite skips unattempted work instead of failing it
.sssssssssssssssssssssssssssssssssssssssssssssssssss [100%]
1 passed, 51 skipped in 0.06s
ok: pytest starter exits 0 on an untouched checkout
ok: the starter suite reports no failures
ok: unwritten exercises are reported as skipped, not passed
ok: collecting both suites at once does not turn skips into passes
5. The lesson's claims, checked one value at a time
ok: the doubled copy is 9.0554 away, which is the article's own length
ok: race-day-nutrition is only 8.0623 away
ok: so Euclidean puts the doubled copy FURTHER than a different article
ok: cosine calls the doubled copy identical
ok: every from-scratch cosine agrees with NumPy inside 1e-12
ok: the three sign cases give 18, 0 and -18
ok: the 45 and 135 degree cases measure 45.00 and 135.00
ok: on normalised vectors the two rankings are identical
ok: on raw vectors with the doubled copy they are NOT
ok: the unit-sphere identity sqrt(2 - 2cos) matches the measured distance
ok: cosine distance fails the triangle inequality (0.585786 < 1.000000)
ok: Euclidean distance holds it on the same triple
ok: the cooking note retrieves roast-chicken
ok: the training query retrieves race-day-nutrition
ok: raw Euclidean gets the cooking note wrong
ok: scaling the query by 100 changes nothing
ok: the zero vector raises rather than returning NaN
ok: the naive formula rounds above 1.0 and the clamp catches it
ok: mean absolute cosine falls at every step up in dimension
ok: and the measured values are reproducible from seed 103
6. The harness can actually fail
ok: a deliberately wrong expectation makes the harness exit non-zero (1)
ok: the failing check is named in the output with both values
ok: the summary line counts exactly one failure
7. Nothing was left behind
ok: no __pycache__ directory anywhere under the lab after a full run
ok: no .pytest_cache directory left under the lab
ok: no lab source opens a network connection
49 checks, 0 failure(s).
Source files
examples/01_the_length_confound.py (7347 bytes)
"""Section 1 — Euclidean distance answers the wrong question about text.
Day 99 measured how far apart two articles were with Euclidean distance, and
that worked well enough that it was easy to miss what it was actually
measuring. Here is the miss, in one number.
Take roast-chicken and write it again at twice the length. Same subject, same
emphasis, every count doubled. Euclidean distance says the doubled copy is
further from the original than an article about race-day nutrition is — which
is mostly about running.
Nothing is wrong with the arithmetic. The question was wrong. "How far apart
are these two points" is not the same question as "are these two articles
about the same thing", and for text they have different answers.
Run from the examples directory:
python3 01_the_length_confound.py
"""
from __future__ import annotations
from catalogue import CATALOGUE, FEATURES, LONG_ROAST_CHICKEN
from similarity import cosine_similarity, euclidean_distance, l2_norm, normalise
TOL = 1e-12
def show_the_two_articles() -> None:
short = CATALOGUE["roast-chicken"]
print("The same article, written twice as long")
print()
header = f" {'article':<26}" + "".join(f"{f:>10}" for f in FEATURES) + f"{'|v|':>10}"
print(header)
print(" " + "-" * (len(header) - 2))
print(f" {'roast-chicken':<26}" + "".join(f"{n:>10}" for n in short)
+ f"{l2_norm(short):>10.4f}")
print(f" {'roast-chicken (2x length)':<26}"
+ "".join(f"{n:>10}" for n in LONG_ROAST_CHICKEN)
+ f"{l2_norm(LONG_ROAST_CHICKEN):>10.4f}")
print()
print(" Every count doubled. The writer said the same things, twice each.")
print()
def show_the_failure() -> None:
short = CATALOGUE["roast-chicken"]
rival = CATALOGUE["race-day-nutrition"]
d_long = euclidean_distance(short, LONG_ROAST_CHICKEN)
d_rival = euclidean_distance(short, rival)
print("Euclidean distance, worked out in full")
print()
diff_long = [a - b for a, b in zip(short, LONG_ROAST_CHICKEN)]
print(" roast-chicken vs its own doubled copy")
print(f" {short} - {LONG_ROAST_CHICKEN} = {diff_long}")
print(f" squares: {' + '.join(str(d * d) for d in diff_long)}"
f" = {sum(d * d for d in diff_long)}")
print(f" sqrt({sum(d * d for d in diff_long)}) = {d_long:.4f}")
print()
diff_rival = [a - b for a, b in zip(short, rival)]
print(" roast-chicken vs race-day-nutrition")
print(f" {short} - {rival} = {diff_rival}")
print(f" squares: {' + '.join(str(d * d) for d in diff_rival)}"
f" = {sum(d * d for d in diff_rival)}")
print(f" sqrt({sum(d * d for d in diff_rival)}) = {d_rival:.4f}")
print()
print(" So Euclidean distance says the doubled copy of roast-chicken")
print(f" ({d_long:.4f}) is FURTHER from roast-chicken than an article")
print(f" about race-day nutrition is ({d_rival:.4f}).")
print()
assert d_long > d_rival, "the confound should put the doubled copy further away"
print(" There is a tidy reason the first number came out the way it did.")
print(" Doubling a vector v gives 2v, and the difference is 2v - v = v, so")
print(" the distance between an article and its doubled copy is exactly the")
print(f" article's own length: |v| = {l2_norm(short):.4f}. Longer articles are")
print(" punished harder, which is the opposite of what a search should do.")
print()
def show_the_fix() -> None:
short = CATALOGUE["roast-chicken"]
rival = CATALOGUE["race-day-nutrition"]
cos_long = cosine_similarity(short, LONG_ROAST_CHICKEN)
cos_rival = cosine_similarity(short, rival)
print("Cosine similarity, on exactly the same numbers")
print()
print(f" cos(roast-chicken, its doubled copy) = {cos_long:.10f}")
print(f" cos(roast-chicken, race-day-nutrition) = {cos_rival:.10f}")
print()
print(" 1.0 means the angle between them is zero: the two vectors point")
print(" in exactly the same direction. They are the same article, and the")
print(" measure says so.")
print()
assert abs(cos_long - 1.0) < TOL, "a scaled copy must have cosine similarity 1"
assert cos_rival < cos_long
print(" Why it is exactly 1 rather than nearly 1: cosine similarity")
print(" divides by both lengths, so scaling either vector by a positive")
print(" number multiplies the top and the bottom by the same factor and")
print(" cancels. Here is that cancellation with the real numbers:")
print()
dot_long = sum(a * b for a, b in zip(short, LONG_ROAST_CHICKEN))
print(f" dot = 9*18 + 0*0 + 1*2 + 0*0 = {dot_long}")
print(f" |v| = {l2_norm(short):.6f}")
print(f" |2v| = {l2_norm(LONG_ROAST_CHICKEN):.6f}"
f" (exactly twice {l2_norm(short):.6f})")
print(f" {dot_long} / ({l2_norm(short):.6f} * "
f"{l2_norm(LONG_ROAST_CHICKEN):.6f}) = {cos_long:.10f}")
print()
unit_short = normalise(short)
unit_long = normalise(LONG_ROAST_CHICKEN)
print(" The same thing said with unit vectors. Normalise both — divide each")
print(" by its own length — and they land on the identical point:")
print()
print(f" unit(roast-chicken) = [{', '.join(f'{x:.6f}' for x in unit_short)}]")
print(f" unit(doubled copy) = [{', '.join(f'{x:.6f}' for x in unit_long)}]")
print(f" distance between them = "
f"{euclidean_distance(unit_short, unit_long):.10f}")
print()
assert euclidean_distance(unit_short, unit_long) < TOL
def show_the_whole_catalogue() -> None:
short = CATALOGUE["roast-chicken"]
print("Both measures against roast-chicken, whole catalogue, doubled copy included")
print()
rows = dict(CATALOGUE)
rows["roast-chicken (2x)"] = LONG_ROAST_CHICKEN
header = f" {'article':<24}{'Euclidean':>12}{'cosine':>12}{'agrees?':>10}"
print(header)
print(" " + "-" * (len(header) - 2))
by_euclid = sorted(rows, key=lambda k: euclidean_distance(short, rows[k]))
by_cosine = sorted(rows, key=lambda k: -cosine_similarity(short, rows[k]))
for label, vector in rows.items():
agrees = by_euclid.index(label) == by_cosine.index(label)
print(f" {label:<24}{euclidean_distance(short, vector):>12.4f}"
f"{cosine_similarity(short, vector):>12.4f}{str(agrees):>10}")
print()
print(f" ranked by Euclidean : {', '.join(by_euclid)}")
print(f" ranked by cosine : {', '.join(by_cosine)}")
print()
print(" The two rankings disagree on RAW counts, and they disagree in the")
print(" place that matters: cosine puts the doubled copy joint first,")
print(" Euclidean puts it fourth. Section 4 shows that once every vector is")
print(" normalised the two measures agree completely — the disagreement is")
print(" entirely about magnitude.")
print()
assert by_euclid != by_cosine, "the whole point is that raw rankings differ"
assert by_cosine[0] in ("roast-chicken", "roast-chicken (2x)")
def main() -> int:
show_the_two_articles()
show_the_failure()
show_the_fix()
show_the_whole_catalogue()
print("01_the_length_confound.py: every assertion held.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/02_dot_product_and_sign.py (6958 bytes)
"""Section 2 — what the dot product IS, and what its sign tells you.
Day 101 computed dot products mechanically: multiply component by component,
add up the results. That is correct and it is not an explanation. Today the
same number gets its geometric meaning:
a dot b = |a| |b| cos(theta)
Read that right to left and it says: the dot product is how much of one vector
lies along the other, scaled by both lengths. Read the sign alone and it says
something simpler and immediately useful — positive means the angle is under
90 degrees, zero means exactly 90, negative means over 90.
Run from the examples directory:
python3 02_dot_product_and_sign.py
"""
from __future__ import annotations
import math
from catalogue import PROJECTION_A, PROJECTION_B, SIGN_CASES
from similarity import (
angle_degrees,
cosine_similarity,
dot,
l2_norm,
scalar_projection,
vector_projection,
)
TOL = 1e-9
def show_the_two_definitions_agree() -> None:
a, b = PROJECTION_A, PROJECTION_B
print("The algebraic definition and the geometric one are the same number")
print()
print(f" a = {a} |a| = {l2_norm(a):.4f} (a 3-4-5 triangle, so exactly 5)")
print(f" b = {b} |b| = {l2_norm(b):.4f}")
print()
products = [x * y for x, y in zip(a, b)]
print(" Algebraic: multiply component by component, then add.")
print(f" {a[0]}*{b[0]} + {a[1]}*{b[1]} = {products[0]} + {products[1]}"
f" = {dot(a, b):.0f}")
print()
theta = angle_degrees(a, b)
cos_theta = cosine_similarity(a, b)
print(" Geometric: |a| |b| cos(theta).")
print(f" theta = {theta:.4f} degrees, cos(theta) = {cos_theta:.4f}")
print(f" {l2_norm(a):.0f} * {l2_norm(b):.0f} * {cos_theta:.4f}"
f" = {l2_norm(a) * l2_norm(b) * cos_theta:.4f}")
print()
assert abs(dot(a, b) - l2_norm(a) * l2_norm(b) * cos_theta) < TOL
print(" Same number, reached two ways. The algebraic route is what a")
print(" computer runs; the geometric route is what it means.")
print()
def show_the_projection() -> None:
a, b = PROJECTION_A, PROJECTION_B
print("The projection picture: how much of b lies along a")
print()
shadow_length = scalar_projection(a, b)
shadow = vector_projection(a, b)
print(" Shine a light straight down onto a's direction. b casts a shadow.")
print(f" length of the shadow = (a dot b) / |a| = {dot(a, b):.0f} /"
f" {l2_norm(a):.0f} = {shadow_length:.4f}")
print(f" the shadow as a vector = [{', '.join(f'{x:.4f}' for x in shadow)}]")
print(f" its length = {l2_norm(shadow):.4f}")
print()
assert abs(l2_norm(shadow) - shadow_length) < TOL
print(f" Check it the other way: |b| cos(theta) = {l2_norm(b):.0f} *"
f" {cosine_similarity(a, b):.4f} = {l2_norm(b) * cosine_similarity(a, b):.4f}")
print()
assert abs(l2_norm(b) * cosine_similarity(a, b) - shadow_length) < TOL
other_way = scalar_projection(b, a)
print(" The projection is NOT symmetric. Projecting a onto b instead:")
print(f" (b dot a) / |b| = {dot(b, a):.0f} / {l2_norm(b):.0f}"
f" = {other_way:.4f}")
print(f" The dot product does not care about order — {dot(a, b):.0f} either")
print(" way — but the shadow does, because you have chosen a different")
print(" surface to cast it on.")
print()
assert abs(dot(a, b) - dot(b, a)) < TOL
assert abs(other_way - shadow_length) > TOL
print(" And the special case that ties Day 99 to today: project a onto")
print(" itself and the shadow is the whole vector.")
print(f" (a dot a) / |a| = {dot(a, a):.0f} / {l2_norm(a):.0f}"
f" = {scalar_projection(a, a):.4f} = |a|")
print(f" so |a| = sqrt(a dot a) = sqrt({dot(a, a):.0f}) = {l2_norm(a):.4f}")
print()
assert abs(scalar_projection(a, a) - l2_norm(a)) < TOL
assert abs(math.sqrt(dot(a, a)) - l2_norm(a)) < TOL
def show_the_sign_cases() -> None:
print("What the sign tells you, one worked example of each")
print()
header = (f" {'case':<20}{'a':>10}{'b':>10}{'a.b':>8}"
f"{'cos':>10}{'angle':>10}{'sign':>11}")
print(header)
print(" " + "-" * (len(header) - 2))
for label, a, b, expected_sign in SIGN_CASES:
value = dot(a, b)
actual_sign = "positive" if value > 0 else ("zero" if value == 0 else "negative")
assert actual_sign == expected_sign, (label, value, expected_sign)
print(f" {label:<20}{str(a):>10}{str(b):>10}{value:>8.0f}"
f"{cosine_similarity(a, b):>10.4f}{angle_degrees(a, b):>10.2f}"
f"{actual_sign:>11}")
print()
print(" Read the middle two columns together and the rule falls out:")
print()
print(" dot > 0 <-> cos > 0 <-> angle under 90 degrees (agreeing)")
print(" dot = 0 <-> cos = 0 <-> angle exactly 90 degrees (unrelated)")
print(" dot < 0 <-> cos < 0 <-> angle over 90 degrees (opposing)")
print()
print(" The sign of the dot product and the sign of the cosine are always")
print(" the same, because the two lengths you divide by are never negative.")
print(" So if all you need is the DIRECTION of the relationship, the raw")
print(" dot product answers it and you can skip both square roots.")
print()
def show_orthogonality_in_the_catalogue() -> None:
from catalogue import CATALOGUE
print("Orthogonality is not an abstraction: three pairs in the catalogue have it")
print()
labels = list(CATALOGUE)
found = []
for i, first in enumerate(labels):
for second in labels[i + 1:]:
value = dot(CATALOGUE[first], CATALOGUE[second])
if value == 0:
found.append((first, second))
for first, second in found:
print(f" {first} . {second} = 0")
pairs = " + ".join(
f"{x}*{y}" for x, y in zip(CATALOGUE[first], CATALOGUE[second])
)
print(f" {pairs} = 0")
print(f" cosine similarity {cosine_similarity(CATALOGUE[first], CATALOGUE[second]):.4f},"
f" angle {angle_degrees(CATALOGUE[first], CATALOGUE[second]):.2f} degrees")
print()
assert ("roast-chicken", "storm-bulletin") in found
print(" Every product in that sum is zero because wherever one article has")
print(" a count the other has none. They share no vocabulary at all, and")
print(" orthogonal is exactly what that means: not opposed, just entirely")
print(" unrelated. Nothing you learn about one tells you anything about")
print(" the other.")
print()
def main() -> int:
show_the_two_definitions_agree()
show_the_projection()
show_the_sign_cases()
show_orthogonality_in_the_catalogue()
print("02_dot_product_and_sign.py: every assertion held.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/03_from_scratch_vs_numpy.py (9745 bytes)
"""Section 3 — fifteen lines of pure Python, checked against NumPy line by line.
Everything today's lesson claims comes out of three functions, and all three
fit on one screen:
dot(a, b) = sum(x*y for x, y in zip(a, b))
cosine_similarity(a, b) = dot(a, b) / (l2_norm(a) * l2_norm(b))
cosine_distance(a, b) = 1 - cosine_similarity(a, b)
This script runs each of them against NumPy's own machinery on every pair in
the catalogue and asserts agreement to a stated tolerance, so that "my version
matches the library" is a measurement rather than a hope. It then shows the
three places where the from-scratch version and the library version genuinely
differ — which is worth more than the agreement, because those are the places
where a real implementation goes wrong.
Run from the examples directory:
python3 03_from_scratch_vs_numpy.py
"""
from __future__ import annotations
import math
import numpy as np
from catalogue import CATALOGUE
from similarity import (
angle_degrees,
cosine_distance,
cosine_similarity,
dot,
l2_norm,
normalise,
)
TOL = 1e-12
def numpy_cosine(a, b) -> float:
"""The same formula in NumPy, written out rather than imported."""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
def show_every_pair_agrees() -> None:
print("Every pair in the catalogue, mine against NumPy's")
print()
labels = list(CATALOGUE)
header = (f" {'pair':<40}{'dot':>8}{'mine':>12}{'numpy':>12}"
f"{'difference':>14}")
print(header)
print(" " + "-" * (len(header) - 2))
worst = 0.0
pairs = 0
for i, first in enumerate(labels):
for second in labels[i + 1:]:
a, b = CATALOGUE[first], CATALOGUE[second]
mine = cosine_similarity(a, b)
theirs = numpy_cosine(a, b)
gap = abs(mine - theirs)
worst = max(worst, gap)
pairs += 1
print(f" {first + ' / ' + second:<40}{dot(a, b):>8.0f}"
f"{mine:>12.6f}{theirs:>12.6f}{gap:>14.2e}")
assert gap < TOL, (first, second, mine, theirs)
print()
print(f" {pairs} pairs, largest disagreement {worst:.2e}, tolerance {TOL:.0e}")
print()
print(" Note that this is agreement, not identity. The two implementations")
print(" add the products up in the same order here, so they happen to")
print(" produce bit-identical answers, but nothing guarantees that in")
print(" general — NumPy is free to reorder a summation for speed, and")
print(" floating-point addition is not associative (Day 70). Compare with")
print(" a tolerance, always.")
print()
def show_the_three_equivalent_routes() -> None:
a = CATALOGUE["roast-chicken"]
b = CATALOGUE["race-day-nutrition"]
print("Three routes to the same cosine similarity")
print()
direct = dot(a, b) / (l2_norm(a) * l2_norm(b))
via_units = dot(normalise(a), normalise(b))
via_numpy = float(np.dot(normalise(a), normalise(b)))
print(f" divide the dot product by both lengths : {direct:.15f}")
print(f" dot product of the two UNIT vectors : {via_units:.15f}")
print(f" the same, through numpy.dot : {via_numpy:.15f}")
print()
assert abs(direct - via_units) < TOL
assert abs(via_units - via_numpy) < TOL
print(" The second route is the one that matters in practice. Normalise")
print(" every vector ONCE when you store it, and every later comparison is")
print(" a bare dot product with no square roots in it at all. That is what")
print(" a vector index does, and it is why 'dot product' and 'cosine' are")
print(" offered as separate options by systems that store embeddings: on")
print(" already-normalised vectors they are the same thing, and the dot")
print(" product is cheaper.")
print()
def show_the_edges() -> None:
print("Three places where the naive formula needs care")
print()
print(" 1. The zero vector. It has no direction, so the angle to it does")
print(" not exist, and the formula divides by zero.")
zero = [0, 0, 0, 0]
with np.errstate(invalid="ignore", divide="ignore"):
naive = np.dot(zero, CATALOGUE["roast-chicken"]) / (
np.linalg.norm(zero) * np.linalg.norm(CATALOGUE["roast-chicken"])
)
print(f" written out in NumPy without a guard : {naive}")
try:
cosine_similarity(zero, CATALOGUE["roast-chicken"])
except ValueError as exc:
print(f" this lab's version : ValueError: {exc}")
else: # pragma: no cover - the guard is asserted below
raise AssertionError("cosine_similarity must refuse the zero vector")
print(" A NaN sorts unpredictably and spreads through every average it")
print(" touches. Raise instead. An empty document is a real thing that")
print(" happens, and it should stop the pipeline, not poison it.")
print()
print(" 2. Rounding past 1. Compare a vector with ITSELF and the answer")
print(" should be exactly 1.0. Here is what it actually is, unclamped,")
print(" for all six articles — plain integer counts, nothing exotic:")
print()
print(f" {'article':<22}{'unclamped (a dot a) / (|a| |a|)':>36}")
print(" " + "-" * 57)
over = []
under = []
for label, vector in CATALOGUE.items():
length = math.sqrt(sum(x * x for x in vector))
unclamped = sum(x * y for x, y in zip(vector, vector)) / (length * length)
print(f" {label:<22}{unclamped!r:>36}")
if unclamped > 1.0:
over.append((label, unclamped))
elif unclamped < 1.0:
under.append((label, unclamped))
print()
print(f" exactly 1.0 : {6 - len(over) - len(under)} of 6")
print(f" just under : {len(under)} of 6")
print(f" just over : {len(over)} of 6")
print()
assert over, "at least one article should round above 1.0"
label, unclamped = over[0]
print(f" The one that rounds UP is {label}, at {unclamped!r},")
print(" and that single bit of rounding is fatal downstream:")
try:
math.acos(unclamped)
except ValueError as exc:
print(f" math.acos({unclamped!r}) -> ValueError: {exc}")
else: # pragma: no cover - the domain error is the point
raise AssertionError("acos above 1.0 should raise")
vector = CATALOGUE[label]
print(f" clamped, this lab gives {cosine_similarity(vector, vector)!r}")
print(f" and an angle of {angle_degrees(vector, vector):.1f} degrees")
print()
print(" Half a dozen four-component integer vectors were enough to")
print(" produce this, which is the point: it is not an exotic case you")
print(" will meet once. Any code that turns a similarity into an angle,")
print(" or asserts a score is at most 1.0, clamps first. This lab")
print(" clamps, and every reported similarity below is a clamped one.")
assert unclamped > 1.0
assert cosine_similarity(vector, vector) == 1.0
print()
print(" 3. Mismatched lengths. Two vectors of different sizes cannot be")
print(" compared, and the failure should be loud.")
try:
dot([1, 2, 3], [1, 2])
except ValueError as exc:
print(f" this lab's version : ValueError: {exc}")
else: # pragma: no cover
raise AssertionError("dot must refuse mismatched lengths")
try:
np.array([1, 2, 3]) @ np.array([1, 2])
except ValueError as exc:
print(f" NumPy : ValueError: {exc}")
else: # pragma: no cover
raise AssertionError("numpy must refuse mismatched lengths")
print(" Both raise ValueError, which is deliberate: one except clause")
print(" catches either implementation.")
print()
def show_cosine_distance() -> None:
print("Cosine distance is just 1 minus the similarity")
print()
header = f" {'pair':<40}{'similarity':>12}{'distance':>12}"
print(header)
print(" " + "-" * (len(header) - 2))
examples = [
("roast-chicken", "roast-chicken"),
("roast-chicken", "slow-cooker-stew"),
("roast-chicken", "race-day-nutrition"),
("roast-chicken", "storm-bulletin"),
]
for first, second in examples:
a, b = CATALOGUE[first], CATALOGUE[second]
print(f" {first + ' / ' + second:<40}{cosine_similarity(a, b):>12.6f}"
f"{cosine_distance(a, b):>12.6f}")
opposed = cosine_distance([1, 0], [-1, 0])
print(f" {'[1, 0] / [-1, 0] (opposite directions)':<40}"
f"{cosine_similarity([1, 0], [-1, 0]):>12.6f}{opposed:>12.6f}")
print()
print(" The range is 0 to 2, not 0 to 1 — because similarity runs from 1")
print(" down to -1. On count vectors, where nothing is ever negative, no")
print(" pair can be more than 90 degrees apart, so the distance never")
print(" exceeds 1 and 'orthogonal' is as far apart as two articles get.")
print(" Embeddings from a trained model DO have negative components, and")
print(" there the upper half of the range is reachable.")
print()
assert abs(opposed - 2.0) < TOL
assert all(
cosine_distance(CATALOGUE[a], CATALOGUE[b]) <= 1.0 + TOL
for a in CATALOGUE
for b in CATALOGUE
)
def main() -> int:
show_every_pair_agrees()
show_the_three_equivalent_routes()
show_the_edges()
show_cosine_distance()
print("03_from_scratch_vs_numpy.py: every assertion held.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/04_same_ranking_on_the_sphere.py (6017 bytes)
"""Section 4 — on normalised vectors the two measures rank identically.
This is the most useful fact in the day, and it is the one most often stated
without proof. Here it is proved twice: once with algebra you can follow with a
pen, and once by ranking the whole catalogue both ways and asserting the orders
match.
The algebra, for two UNIT vectors u and v:
|u - v|^2 = (u - v) dot (u - v)
= u dot u - 2 (u dot v) + v dot v
= 1 - 2 (u dot v) + 1
= 2 - 2 cos(theta)
so Euclidean distance = sqrt(2 - 2 cos)
Cosine goes up, the bracket goes down, the square root goes down. The distance
is a strictly decreasing function of the similarity, so sorting by one gives
exactly the reverse of sorting by the other. Not approximately. Exactly.
That is why a vector database normalises its vectors on the way in and then
uses whichever comparison its hardware runs fastest: on the unit sphere the
choice is a performance decision, not a semantic one.
Run from the examples directory:
python3 04_same_ranking_on_the_sphere.py
"""
from __future__ import annotations
import math
from catalogue import CATALOGUE, LONG_ROAST_CHICKEN
from similarity import (
cosine_similarity,
euclidean_distance,
normalise,
normalise_all,
rank_by_cosine,
rank_by_euclidean,
)
TOL = 1e-12
def show_the_identity() -> None:
print("The identity, checked on every pair in the catalogue")
print()
units = normalise_all(CATALOGUE)
labels = list(units)
header = (f" {'pair':<40}{'cos':>10}{'sqrt(2-2cos)':>14}"
f"{'|u - v|':>12}{'gap':>12}")
print(header)
print(" " + "-" * (len(header) - 2))
worst = 0.0
for i, first in enumerate(labels):
for second in labels[i + 1:]:
u, v = units[first], units[second]
cos = cosine_similarity(u, v)
predicted = math.sqrt(2 - 2 * cos)
measured = euclidean_distance(u, v)
gap = abs(predicted - measured)
worst = max(worst, gap)
print(f" {first + ' / ' + second:<40}{cos:>10.6f}{predicted:>14.6f}"
f"{measured:>12.6f}{gap:>12.2e}")
assert gap < TOL
print()
print(f" Largest gap between the formula and the measurement: {worst:.2e}")
print()
def show_the_rankings_match() -> None:
print("Rank the catalogue both ways, from the same query, after normalising")
print()
query = CATALOGUE["roast-chicken"]
unit_query = normalise(query)
units = normalise_all(CATALOGUE)
by_cosine = rank_by_cosine(unit_query, units)
by_euclid = rank_by_euclidean(unit_query, units)
header = (f" {'rank':<6}{'by cosine (high first)':<26}{'sim':>10} "
f"{'by distance (low first)':<26}{'dist':>10}")
print(header)
print(" " + "-" * (len(header) - 2))
for position, (left, right) in enumerate(zip(by_cosine, by_euclid), start=1):
print(f" {position:<6}{left[0]:<26}{left[1]:>10.6f} "
f"{right[0]:<26}{right[1]:>10.6f}")
print()
cosine_order = [label for label, _ in by_cosine]
euclid_order = [label for label, _ in by_euclid]
print(f" the two orders are identical : {cosine_order == euclid_order}")
print()
assert cosine_order == euclid_order
print(" Now the same comparison WITHOUT normalising, to show what the")
print(" normalisation was doing. The doubled copy of roast-chicken is")
print(" added so the length difference is real:")
print()
raw = dict(CATALOGUE)
raw["roast-chicken (2x)"] = LONG_ROAST_CHICKEN
raw_cosine = [label for label, _ in rank_by_cosine(query, raw)]
raw_euclid = [label for label, _ in rank_by_euclidean(query, raw)]
print(f" by cosine : {', '.join(raw_cosine)}")
print(f" by distance : {', '.join(raw_euclid)}")
print(f" identical? : {raw_cosine == raw_euclid}")
print()
assert raw_cosine != raw_euclid
print(" So the claim is precise and worth stating precisely: the two")
print(" measures agree on NORMALISED vectors and can disagree on raw ones.")
print(" Normalising is not a tidying step you do out of habit. It is the")
print(" step that makes the two measures interchangeable, and if you skip")
print(" it, which one you picked changes the answers.")
print()
def show_the_monotone_curve() -> None:
print("The curve behind the claim, sampled")
print()
print(f" {'cosine':>10}{'distance on the unit sphere':>30}{'angle':>10}")
print(" " + "-" * 48)
previous = None
for cos in (1.0, 0.9, 0.5, 0.0, -0.5, -0.9, -1.0):
distance = math.sqrt(2 - 2 * cos)
angle = math.degrees(math.acos(cos))
print(f" {cos:>10.1f}{distance:>30.6f}{angle:>10.1f}")
if previous is not None:
assert distance > previous, "distance must rise as cosine falls"
previous = distance
print()
print(" Every step down in cosine is a step up in distance, with no")
print(" exceptions and no flat stretches. Two unit vectors pointing the")
print(" same way are 0 apart; perpendicular ones are sqrt(2) = 1.414214")
print(" apart; opposite ones are 2 apart, which is the diameter of the")
print(" sphere and the furthest two unit vectors can get.")
print()
print(" What this does NOT say: the two measures produce the same SCORES.")
print(" They do not, and a threshold tuned for one is meaningless for the")
print(" other. A cut-off of 'similarity above 0.9' is 'distance below")
print(f" {math.sqrt(2 - 2 * 0.9):.6f}', and nothing about the second number is")
print(" guessable from the first. Only the ORDER is preserved.")
print()
def main() -> int:
show_the_identity()
show_the_rankings_match()
show_the_monotone_curve()
print("04_same_ranking_on_the_sphere.py: every assertion held.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/05_not_a_metric.py (7468 bytes)
"""Section 5 — cosine distance is not a metric, and here is the counter-example.
"Distance" is a word with a technical meaning. A function d is a metric when it
satisfies four conditions:
1. d(a, b) >= 0 never negative
2. d(a, b) = 0 exactly when a = b identity of indiscernibles
3. d(a, b) = d(b, a) symmetry
4. d(a, c) <= d(a, b) + d(b, c) the triangle inequality
Cosine distance satisfies 1, 3 and half of 2, and fails 4. It also fails the
other half of 2, because any two vectors pointing the same way — an article and
its doubled copy — are at distance 0 without being equal, which is exactly the
property that made it useful an hour ago.
The failure of 4 is the one that costs you something. A great deal of fast
search machinery — ball trees, KD-trees, metric indexes, anything that prunes
"this whole branch is too far away to contain the answer" — is built on the
triangle inequality. Feed it a function that fails the inequality and it will
still return answers, and some of them will be wrong, and nothing will say so.
This is not a reason to avoid cosine similarity. It is a reason to normalise
your vectors and hand the index Euclidean distance, which IS a metric, knowing
from section 4 that the ranking is identical.
Run from the examples directory:
python3 05_not_a_metric.py
"""
from __future__ import annotations
import math
from catalogue import TRIANGLE_A, TRIANGLE_B, TRIANGLE_C
from similarity import (
angle_degrees,
cosine_distance,
cosine_similarity,
euclidean_distance,
normalise,
)
TOL = 1e-12
def show_the_counter_example() -> None:
a, b, c = TRIANGLE_A, TRIANGLE_B, TRIANGLE_C
print("Three vectors, chosen so every number is exact on paper")
print()
print(f" a = {a} pointing straight along the first axis")
print(f" b = {b} the bisector, 45 degrees from each")
print(f" c = {c} pointing straight along the second axis")
print()
ab = cosine_distance(a, b)
bc = cosine_distance(b, c)
ac = cosine_distance(a, c)
print("Their cosine distances")
print()
for label, first, second, value in (
("d(a, b)", a, b, ab),
("d(b, c)", b, c, bc),
("d(a, c)", a, c, ac),
):
print(f" {label} = 1 - cos = 1 - {cosine_similarity(first, second):.6f}"
f" = {value:.6f}"
f" (angle {angle_degrees(first, second):.1f} degrees)")
print()
print(f" a to b is 1 - 1/sqrt(2) = 1 - {1 / math.sqrt(2):.6f}"
f" = {1 - 1 / math.sqrt(2):.6f}, and so is b to c.")
print()
print("The triangle inequality, tested")
print()
print(f" going the long way round : d(a, b) + d(b, c) = {ab:.6f}"
f" + {bc:.6f} = {ab + bc:.6f}")
print(f" going direct : d(a, c) = {ac:.6f}")
print(f" is direct <= long way? : {ac <= ab + bc + TOL}")
print()
print(f" The direct route is longer than the detour, by {ac - (ab + bc):.6f}.")
print(" That is not a rounding artefact and it is not a bug in the code.")
print(" It is what 'not a metric' means, in one line of arithmetic.")
print()
assert ac > ab + bc + TOL, "cosine distance must fail the triangle inequality here"
def show_euclidean_holds() -> None:
a, b, c = TRIANGLE_A, TRIANGLE_B, TRIANGLE_C
print("The same three points under Euclidean distance, which IS a metric")
print()
ua, ub, uc = normalise(a), normalise(b), normalise(c)
print(" On the raw vectors:")
print(f" d(a, b) + d(b, c) = {euclidean_distance(a, b):.6f} +"
f" {euclidean_distance(b, c):.6f} = "
f"{euclidean_distance(a, b) + euclidean_distance(b, c):.6f}")
print(f" d(a, c) = {euclidean_distance(a, c):.6f}")
print(f" holds? = "
f"{euclidean_distance(a, c) <= euclidean_distance(a, b) + euclidean_distance(b, c) + TOL}")
print()
print(" And on the normalised ones, which is the case that matters,")
print(" because section 4 said to normalise and then use Euclidean:")
print(f" d(a, b) + d(b, c) = {euclidean_distance(ua, ub):.6f} +"
f" {euclidean_distance(ub, uc):.6f} = "
f"{euclidean_distance(ua, ub) + euclidean_distance(ub, uc):.6f}")
print(f" d(a, c) = {euclidean_distance(ua, uc):.6f}")
print(f" holds? = "
f"{euclidean_distance(ua, uc) <= euclidean_distance(ua, ub) + euclidean_distance(ub, uc) + TOL}")
print()
assert euclidean_distance(a, c) <= euclidean_distance(a, b) + euclidean_distance(b, c) + TOL
assert euclidean_distance(ua, uc) <= euclidean_distance(ua, ub) + euclidean_distance(ub, uc) + TOL
def show_the_angle_holds() -> None:
a, b, c = TRIANGLE_A, TRIANGLE_B, TRIANGLE_C
print("The angle itself, on the same triple")
print()
ab = angle_degrees(a, b)
bc = angle_degrees(b, c)
ac = angle_degrees(a, c)
print(f" angle(a, b) + angle(b, c) = {ab:.1f} + {bc:.1f} = {ab + bc:.1f} degrees")
print(f" angle(a, c) = {ac:.1f} degrees")
print(f" holds, with equality? = {abs((ab + bc) - ac) < 1e-9}")
print()
print(" It holds here, and it holds with equality, because b sits exactly")
print(" on the shortest path from a to c — the three vectors are in one")
print(" plane and b is the halfway point. That is the degenerate case of a")
print(" triangle, a straight line, where the inequality becomes equality.")
print()
print(" Being careful about what this does and does not show: one triple")
print(" where a rule holds is not a proof that it always holds. The angle")
print(" between two vectors is arc length on the unit sphere, and great-")
print(" circle distance on a sphere is known to be a metric — but that")
print(" proof is not in this lab, and this run does not supply it. What")
print(" this run does supply is the counter-example above, and one")
print(" counter-example IS a proof, of the negative: cosine distance is")
print(" not a metric, demonstrated, not asserted.")
print()
assert abs((ab + bc) - ac) < 1e-9
def show_the_identity_failure() -> None:
print("The other condition it fails, and why you wanted it to")
print()
short = [9, 0, 1, 0]
doubled = [18, 0, 2, 0]
distance = cosine_distance(short, doubled)
print(f" d({short}, {doubled}) = {distance:.6f}")
print(f" are the two vectors equal? {short == doubled}")
print()
print(" A metric must have d(a, b) = 0 only when a and b are the same")
print(" point. Cosine distance gives 0 for every pair on the same ray from")
print(" the origin. That is a failure of the definition and the entire")
print(" reason the measure is useful for text: 'same direction' and 'same")
print(" vector' are different claims, and for documents you want the first")
print(" one. The measure is not broken. It is answering the question you")
print(" asked, and that question was never 'are these the same point'.")
print()
assert abs(distance) < TOL
assert short != doubled
def main() -> int:
show_the_counter_example()
show_euclidean_holds()
show_the_angle_holds()
show_the_identity_failure()
print("05_not_a_metric.py: every assertion held.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/06_semantic_search.py (6657 bytes)
"""Section 6 — a semantic search, complete, in about fifteen lines.
Everything in this file that does actual work is imported from
`similarity.py`, and the search itself is four lines:
def search(query, catalogue, k=3):
scored = [(label, cosine_similarity(query, v)) for label, v in catalogue.items()]
scored.sort(key=lambda pair: (-pair[1], pair[0]))
return scored[:k]
That is the retrieval step of a document-answering system. Everything a real
one adds is about getting better vectors — a trained model instead of hand
counts — and about searching millions of them quickly instead of six of them
exhaustively. The comparison at the centre does not change.
Run from the examples directory:
python3 06_semantic_search.py
"""
from __future__ import annotations
from catalogue import CATALOGUE, FEATURES, QUERIES
from similarity import (
angle_degrees,
cosine_similarity,
dot,
euclidean_distance,
l2_norm,
normalise_all,
rank_by_cosine,
rank_by_euclidean,
)
TOL = 1e-12
def search(query, catalogue, k=3):
"""The whole retrieval step. Score everything, sort, take the top k."""
scored = [
(label, cosine_similarity(query, vector)) for label, vector in catalogue.items()
]
scored.sort(key=lambda pair: (-pair[1], pair[0]))
return scored[:k]
def show_the_index() -> None:
print("The index: six articles, four features, and their unit vectors")
print()
units = normalise_all(CATALOGUE)
header = (f" {'article':<20}" + "".join(f"{f:>9}" for f in FEATURES)
+ f"{'|v|':>9} unit vector")
print(header)
print(" " + "-" * (len(header) - 2))
for label, vector in CATALOGUE.items():
unit = "[" + ", ".join(f"{x:.3f}" for x in units[label]) + "]"
print(f" {label:<20}" + "".join(f"{n:>9}" for n in vector)
+ f"{l2_norm(vector):>9.4f} {unit}")
print()
print(" Normalising once, on the way in, is what a vector store does. After")
print(" this every query is a dot product and there is not a square root")
print(" left in the hot path.")
print()
for label, unit in units.items():
assert abs(l2_norm(unit) - 1.0) < 1e-12, label
def run_one_query(text: str, query, expected_top: str) -> None:
print(f"Query: \"{text}\" -> {query}")
print()
header = (f" {'rank':<6}{'article':<22}{'a.b':>7}{'cosine':>10}"
f"{'angle':>9}{'euclid (raw)':>15}")
print(header)
print(" " + "-" * (len(header) - 2))
ranked = rank_by_cosine(query, CATALOGUE)
for position, (label, score) in enumerate(ranked, start=1):
vector = CATALOGUE[label]
print(f" {position:<6}{label:<22}{dot(query, vector):>7.0f}{score:>10.6f}"
f"{angle_degrees(query, vector):>9.2f}"
f"{euclidean_distance(query, vector):>15.4f}")
print()
top, top_score = ranked[0]
runner, runner_score = ranked[1]
print(f" top result : {top} at {top_score:.6f}")
print(f" runner-up : {runner} at {runner_score:.6f}")
print(f" margin : {top_score - runner_score:.6f}")
print()
assert top == expected_top, (text, top, expected_top)
raw_euclid = rank_by_euclidean(query, CATALOGUE)
print(" For contrast, the same query ranked by RAW Euclidean distance:")
print(f" {', '.join(label for label, _ in raw_euclid)}")
print(f" nearest by distance: {raw_euclid[0][0]} at {raw_euclid[0][1]:.4f}")
if raw_euclid[0][0] != top:
print(" which is NOT the cosine winner. Raw distance is still being")
print(" dominated by how long each article is.")
else:
print(" which agrees with cosine here — agreement is possible, it is")
print(" just not guaranteed while the vectors have different lengths.")
print()
by_dot = sorted(
CATALOGUE, key=lambda label: (-dot(query, CATALOGUE[label]), label)
)
print(" And by the RAW dot product, with no division at all:")
print(f" {', '.join(by_dot)}")
print(f" highest dot product: {by_dot[0]} at {dot(query, CATALOGUE[by_dot[0]]):.0f}")
if by_dot[0] != top:
print(" also NOT the cosine winner. The dot product rewards long")
print(" articles, because a longer vector has more of everything to")
print(" multiply. Dot product and cosine are the same ranking only")
print(" after the vectors are normalised.")
else:
print(" which agrees with cosine here, but only by luck of these")
print(" particular lengths.")
print()
def show_top_three() -> None:
print("The four-line search, used the way it would be used")
print()
for text, query in QUERIES.items():
hits = search(query, CATALOGUE, k=3)
print(f" \"{text}\"")
for position, (label, score) in enumerate(hits, start=1):
print(f" {position}. {label:<22}{score:.4f}")
print()
def show_magnitude_does_not_matter() -> None:
print("The query's own length is irrelevant, which is worth proving")
print()
base = QUERIES["roast it"]
header = f" {'query':<22}{'roast-chicken':>16}{'slow-cooker-stew':>20}"
print(header)
print(" " + "-" * (len(header) - 2))
first = None
for factor in (1, 3, 100):
scaled = [factor * x for x in base]
chicken = cosine_similarity(scaled, CATALOGUE["roast-chicken"])
stew = cosine_similarity(scaled, CATALOGUE["slow-cooker-stew"])
print(f" {str(scaled):<22}{chicken:>16.10f}{stew:>20.10f}")
if first is None:
first = (chicken, stew)
else:
assert abs(chicken - first[0]) < TOL
assert abs(stew - first[1]) < TOL
print()
print(" Identical to ten decimal places. A one-word query and the same")
print(" word repeated a hundred times rank the catalogue exactly the same,")
print(" because the only thing cosine reads is the direction. Under raw")
print(" Euclidean distance those three queries give three different")
print(" answers, and Day 99 showed the shortest of them picking the wrong")
print(" article.")
print()
def main() -> int:
show_the_index()
run_one_query("roast it", QUERIES["roast it"], "roast-chicken")
run_one_query(
"training for a race and what to eat",
QUERIES["training for a race and what to eat"],
"race-day-nutrition",
)
show_top_three()
show_magnitude_does_not_matter()
print("06_semantic_search.py: every assertion held.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/07_curse_of_dimensionality.py (9720 bytes)
"""Section 7 — as the dimension grows, random vectors become nearly orthogonal.
Real embeddings do not have four components. They have hundreds or thousands.
Something happens in that space which has no analogue in two or three
dimensions, and which is worth measuring rather than being told:
* two random directions become almost perpendicular, so almost every pair of
unrelated things scores near 0;
* the distances between random points bunch up, so the nearest and the
furthest point in a random cloud stop being very different.
Both are measured here with a seeded generator, so the numbers below are
reproducible: numpy.random.default_rng(103) on numpy 2.5.2. Re-run it and you
get the same values. Change the seed and the numbers move a little; the shape
of the result does not.
The vectors are drawn from a standard normal distribution, which is the usual
choice because it gives a direction that is uniform over the sphere. Drawing
each component uniformly from a box would bias the directions towards the
corners.
Run from the examples directory:
python3 07_curse_of_dimensionality.py
"""
from __future__ import annotations
import math
import numpy as np
SEED = 103
PAIRS = 2000
DIMENSIONS = (2, 3, 8, 32, 128, 512, 2048, 8192)
def exact_mean_abs_cos(dimension: int) -> float:
"""The exact mean of |cos| for two independent random directions in d dims.
E|cos| = gamma(d/2) / (sqrt(pi) * gamma((d+1)/2))
Two values are checkable by hand and both come out of this expression:
in 2 dimensions the angle is uniform over the circle so the answer is
2/pi = 0.63662, and in 3 dimensions the cosine itself is uniform over
-1 to 1 so the answer is exactly 0.5.
"""
return math.exp(
math.lgamma(dimension / 2)
- 0.5 * math.log(math.pi)
- math.lgamma((dimension + 1) / 2)
)
def cosine_rows(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Cosine similarity of matching rows of two arrays, vectorised."""
numerator = np.einsum("ij,ij->i", a, b)
denominator = np.linalg.norm(a, axis=1) * np.linalg.norm(b, axis=1)
return np.clip(numerator / denominator, -1.0, 1.0)
def measure() -> list[dict[str, float]]:
rng = np.random.default_rng(SEED)
rows = []
for dimension in DIMENSIONS:
a = rng.standard_normal((PAIRS, dimension))
b = rng.standard_normal((PAIRS, dimension))
cosines = cosine_rows(a, b)
angles = np.degrees(np.arccos(cosines))
rows.append(
{
"dimension": dimension,
"mean_abs_cos": float(np.mean(np.abs(cosines))),
"max_abs_cos": float(np.max(np.abs(cosines))),
"mean_angle": float(np.mean(angles)),
"std_angle": float(np.std(angles)),
"exact": exact_mean_abs_cos(dimension),
"asymptotic": math.sqrt(2.0 / (math.pi * dimension)),
"frac_within_10_deg": float(np.mean(np.abs(angles - 90.0) < 10.0)),
}
)
return rows
def show_orthogonality(rows) -> None:
print(f"Mean |cosine| between {PAIRS} random vector pairs, by dimension")
print(f"(numpy.random.default_rng({SEED}), standard normal components)")
print()
header = (f" {'dimension':>10}{'mean |cos|':>13}{'exact':>10}"
f"{'sqrt(2/(pi d))':>16}{'max |cos|':>12}{'mean angle':>13}"
f"{'sd of angle':>13}{'within 10 deg':>15}")
print(header)
print(" " + "-" * (len(header) - 2))
for row in rows:
print(f" {row['dimension']:>10}{row['mean_abs_cos']:>13.4f}"
f"{row['exact']:>10.4f}{row['asymptotic']:>16.4f}"
f"{row['max_abs_cos']:>12.4f}"
f"{row['mean_angle']:>13.2f}{row['std_angle']:>13.2f}"
f"{row['frac_within_10_deg'] * 100:>14.1f}%")
print()
first, last = rows[0], rows[-1]
print(f" From dimension {first['dimension']} to dimension {last['dimension']},"
f" the mean absolute cosine")
print(f" fell from {first['mean_abs_cos']:.4f} to {last['mean_abs_cos']:.4f}"
f" — a factor of {first['mean_abs_cos'] / last['mean_abs_cos']:.0f}.")
print(f" The fraction of pairs within 10 degrees of a right angle rose from")
print(f" {first['frac_within_10_deg'] * 100:.1f}% to"
f" {last['frac_within_10_deg'] * 100:.1f}%.")
print()
for earlier, later in zip(rows, rows[1:]):
assert later["mean_abs_cos"] < earlier["mean_abs_cos"], (earlier, later)
assert rows[-1]["mean_abs_cos"] < 0.02
worst_exact = max(
abs(row["mean_abs_cos"] - row["exact"]) / row["exact"] for row in rows
)
worst_asymptotic = max(
abs(row["exact"] - row["asymptotic"]) / row["exact"] for row in rows
)
print(" Two predictions sit beside the measurement, and the difference")
print(" between them is worth a paragraph. The 'exact' column is")
print(" gamma(d/2) / (sqrt(pi) gamma((d+1)/2)), the true mean of |cos| for")
print(" two independent random directions. The last column is the")
print(" approximation sqrt(2 / (pi d)), which is the one usually quoted.")
print()
print(f" The measurement matches the exact value to within"
f" {worst_exact * 100:.1f}% at every")
print(f" dimension, on {PAIRS} pairs. The approximation is off by up to"
f" {worst_asymptotic * 100:.0f}%")
print(" at the small dimensions — it is a large-d limit, and at d = 2 and")
print(" d = 3 it simply is not the right number. Two hand-checkable cases")
print(" settle which column to trust: in 2 dimensions the angle is uniform")
print(" around the circle so the mean of |cos| is 2/pi = 0.63662, and in 3")
print(" dimensions the cosine itself is uniform from -1 to 1 so the mean is")
print(" exactly 0.5. The exact column gives both; the approximation gives")
print(" neither; the measurement agrees with the exact column. This is a")
print(" small thing, and it is the habit that matters: when a run and a")
print(" quoted formula disagree, find out which one is answering your")
print(" question before assuming the run is wrong.")
print()
for row in rows:
assert abs(row["mean_abs_cos"] - row["exact"]) / row["exact"] < 0.05
assert abs(exact_mean_abs_cos(2) - 2 / math.pi) < 1e-12
assert abs(exact_mean_abs_cos(3) - 0.5) < 1e-12
print(" None of that changes the headline, which is the first column")
print(" falling towards zero as the dimension grows.")
print()
def show_distance_concentration() -> None:
print("The second half of the curse: distances bunch up")
print()
rng = np.random.default_rng(SEED + 1)
points = 500
header = (f" {'dimension':>10}{'nearest':>12}{'furthest':>12}"
f"{'ratio':>10}{'spread / mean':>16}")
print(header)
print(" " + "-" * (len(header) - 2))
ratios = []
for dimension in DIMENSIONS:
cloud = rng.standard_normal((points, dimension))
query = rng.standard_normal(dimension)
distances = np.linalg.norm(cloud - query, axis=1)
nearest = float(distances.min())
furthest = float(distances.max())
ratio = furthest / nearest
spread = float(distances.std() / distances.mean())
ratios.append(ratio)
print(f" {dimension:>10}{nearest:>12.4f}{furthest:>12.4f}"
f"{ratio:>10.4f}{spread:>16.4f}")
print()
print(f" In {DIMENSIONS[0]} dimensions the furthest of {points} random points")
print(f" is {ratios[0]:.1f} times as far as the nearest. In"
f" {DIMENSIONS[-1]} dimensions it is {ratios[-1]:.2f} times as far.")
print(" 'Nearest neighbour' still has an answer, but the answer stops being")
print(" meaningfully nearer than everything else, and small errors in the")
print(" vectors start deciding the winner.")
print()
assert ratios[-1] < ratios[0]
assert ratios[-1] < 1.5
def show_what_it_means() -> None:
print("What this changes about reading a similarity score")
print()
print(" A cosine similarity of 0.3 is a strong signal in 1000 dimensions")
print(" and unremarkable in 2, because in 1000 dimensions two unrelated")
print(" things score near 0 and almost nothing lands at 0.3 by accident.")
print(" The table above is the calibration: in this run at dimension 512")
print(" the mean |cos| between unrelated pairs was already under 0.04.")
print()
print(" Three practical consequences, in order of how often they bite:")
print()
print(" 1. Never read a raw similarity score without knowing the")
print(" dimension. 'Above 0.8 means relevant' is a claim about one")
print(" model in one space, not a general fact.")
print(" 2. Calibrate against your own data. Score a few hundred pairs you")
print(" know are unrelated, look at the distribution, and set the")
print(" threshold from that rather than from a number in a blog post.")
print(" 3. Expect exact nearest-neighbour search to stop paying for")
print(" itself as the dimension grows. When the nearest point is barely")
print(" nearer than the tenth nearest, an approximate index that is")
print(" usually right is a good trade — which is what real vector")
print(" databases do, and why they are called approximate.")
print()
def main() -> int:
rows = measure()
show_orthogonality(rows)
show_distance_concentration()
show_what_it_means()
print("07_curse_of_dimensionality.py: every assertion held.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/catalogue.py (2469 bytes)
"""The Day 99 article set, unchanged, plus the queries this lab asks of it.
Six short invented articles, each described by four hand-counted features: how
many times the article talks about cooking, about running, about money, and
about weather. Day 99 used exactly these numbers to introduce vectors, norms
and Euclidean distance. Today they answer a better question, and the fact that
the data has not changed is the point — the measure changed, and that was
enough to change every answer.
Nothing here is real. The articles do not exist and the counts were chosen by
hand so that every number in this lab can be re-derived with a pen.
"""
from __future__ import annotations
FEATURES = ("cooking", "running", "money", "weather")
CATALOGUE = {
"roast-chicken": [9, 0, 1, 0],
"slow-cooker-stew": [8, 0, 2, 0],
"marathon-plan": [0, 9, 1, 2],
"race-day-nutrition": [4, 6, 3, 0],
"household-budget": [1, 0, 9, 0],
"storm-bulletin": [0, 1, 0, 9],
}
# The same article, written at twice the length: every count doubled. Same
# subject, same emphasis, twice as many words. This is the vector that breaks
# Euclidean distance in section 1.
LONG_ROAST_CHICKEN = [18, 0, 2, 0]
# Two queries, written as feature counts the same way the articles were.
QUERIES = {
# A one-line cooking note: "roast it".
"roast it": [1, 0, 0, 0],
# "training for a race and what to eat" — two counts for cooking, five for
# running. Deliberately a close call between two articles; the lab reports
# the margin rather than pretending the winner was obvious.
"training for a race and what to eat": [2, 5, 0, 0],
}
# Three vectors whose cosine distances break the triangle inequality. Chosen so
# every number is exact on paper: two axis vectors and the bisector between
# them.
TRIANGLE_A = [1, 0]
TRIANGLE_B = [1, 1]
TRIANGLE_C = [0, 1]
# The sign cases. Each pair is (label, a, b, expected sign of a dot b), with
# `a` fixed so only the second vector's direction changes.
SIGN_CASES = (
("same direction", [3, 0], [6, 0], "positive"),
("45 degrees apart", [3, 0], [1, 1], "positive"),
("perpendicular", [3, 0], [0, 5], "zero"),
("135 degrees apart", [3, 0], [-2, 2], "negative"),
("opposite direction", [3, 0], [-6, 0], "negative"),
)
# The projection picture: a 3-4-5 triangle, so the arithmetic is exact.
PROJECTION_A = [3, 4] # length 5
PROJECTION_B = [10, 0] # length 10
examples/conftest.py (1124 bytes)
"""Make this directory's own similarity.py the one its tests import.
Both `examples/` and `starter/` contain a module called `similarity`, and
pytest imports test files by putting their directory on `sys.path`. Without
this file, running `pytest` across both directories at once would import
whichever `similarity` was seen first and reuse it for the other suite — so the
starter tests would silently pass against the reference solution instead of
skipping. That is a wrong answer with a green tick on it, which is the worst
kind, and Day 100 shipped a version of this bug before it was caught.
So: put this directory first on the import path, and drop any already-imported
`similarity` or `catalogue` that came from somewhere else.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("similarity", "catalogue", "answers"):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
examples/similarity.py (7141 bytes)
"""The reference implementation: dot product, cosine similarity, cosine distance.
Pure Python, no imports beyond the standard library's `math`. Every function
here is a few lines, and together they are the arithmetic underneath every
semantic search and every retrieval step in a document-answering system.
This is the answer key for `starter/similarity.py`. Read it after you have
written yours, not before.
Design notes worth carrying into any real implementation:
* Every function that compares two vectors checks that the lengths match
first, and raises ValueError rather than returning a wrong number quietly.
NumPy raises ValueError for the same mistake, so one `except ValueError`
catches both.
* `cosine_similarity` refuses the zero vector instead of returning NaN. A zero
vector has no direction, so "which way does it point" has no answer, and a
silent NaN travelling through a ranking is much worse than a loud error.
* Results are clamped to [-1, 1]. Floating point can hand you 1.0000000000000002
for a vector compared with itself, and `math.acos` of that raises ValueError
— a real failure this lab hit while it was being written.
"""
from __future__ import annotations
import math
from typing import Iterable, Sequence
def _check_same_length(a: Sequence[float], b: Sequence[float]) -> None:
if len(a) != len(b):
raise ValueError(
f"vectors must have the same number of components: "
f"got {len(a)} and {len(b)}"
)
def dot(a: Sequence[float], b: Sequence[float]) -> float:
"""Multiply the two vectors component by component, then add it all up.
That is the whole definition. a dot b = a1*b1 + a2*b2 + ... + an*bn, and
the answer is a single number, not a vector.
"""
_check_same_length(a, b)
return float(sum(x * y for x, y in zip(a, b)))
def l2_norm(a: Sequence[float]) -> float:
"""The length of the vector: the square root of the sum of its squares.
Note that l2_norm(a) is exactly sqrt(dot(a, a)) — a vector's length is the
dot product of the vector with itself, square-rooted. Day 99 defined this
with Pythagoras; today it turns out to be a special case of the dot
product, which is the first hint that the dot product is the more
fundamental operation.
"""
return math.sqrt(dot(a, a))
def normalise(a: Sequence[float]) -> list[float]:
"""Return the unit vector pointing the same way: same direction, length 1."""
length = l2_norm(a)
if length == 0.0:
raise ValueError("the zero vector has no direction, so it cannot be normalised")
return [x / length for x in a]
def euclidean_distance(a: Sequence[float], b: Sequence[float]) -> float:
"""The straight-line distance between the two points: the length of a - b."""
_check_same_length(a, b)
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
"""The cosine of the angle between the two vectors, in the range -1 to 1.
a dot b = |a| |b| cos(theta), so cos(theta) = (a dot b) / (|a| |b|). The two
divisions are what make this magnitude-free: scaling either vector by any
positive number multiplies the top and the bottom by the same factor, and
the answer does not move.
Equivalently, and this is worth knowing because it is how a vector database
ends up doing it: cosine similarity is the plain dot product of the two
UNIT vectors. Normalise once, and every later comparison is a dot product.
"""
_check_same_length(a, b)
na, nb = l2_norm(a), l2_norm(b)
if na == 0.0 or nb == 0.0:
raise ValueError(
"cosine similarity is undefined when either vector is the zero "
"vector: it has no direction to compare"
)
return _clamp(dot(a, b) / (na * nb))
def cosine_distance(a: Sequence[float], b: Sequence[float]) -> float:
"""1 minus the cosine similarity: 0 for identical direction, 2 for opposite.
Called a distance because it grows as things get less alike, but it is NOT
a metric — it fails the triangle inequality, and section 5 of this lab
shows a concrete triple where it does.
"""
return 1.0 - cosine_similarity(a, b)
def angle_degrees(a: Sequence[float], b: Sequence[float]) -> float:
"""The angle between the two vectors, in degrees, from 0 to 180."""
return math.degrees(math.acos(cosine_similarity(a, b)))
def scalar_projection(a: Sequence[float], b: Sequence[float]) -> float:
"""How much of b lies along a: the length of b's shadow on a's direction.
(a dot b) / |a|. Note the asymmetry — this is b measured against a's
direction, so swapping the arguments generally changes the answer, even
though the dot product itself does not care about order.
"""
na = l2_norm(a)
if na == 0.0:
raise ValueError("cannot project onto the zero vector: it has no direction")
return dot(a, b) / na
def vector_projection(a: Sequence[float], b: Sequence[float]) -> list[float]:
"""The shadow itself: the part of b that points along a, as a vector."""
unit_a = normalise(a)
length = dot(unit_a, b)
return [length * x for x in unit_a]
def _clamp(value: float, low: float = -1.0, high: float = 1.0) -> float:
"""Keep a cosine inside [-1, 1] despite floating-point rounding."""
return max(low, min(high, value))
def rank_by_cosine(
query: Sequence[float], catalogue: dict[str, Sequence[float]]
) -> list[tuple[str, float]]:
"""Every item scored against the query, best first. This is the search.
Ties are broken by name so the ranking is deterministic and testable; a
real system would break them by document id or by insertion order, and it
matters that it breaks them by SOMETHING, because an unstable sort makes a
ranking that changes between runs.
"""
scored = [
(label, cosine_similarity(query, vector)) for label, vector in catalogue.items()
]
return sorted(scored, key=lambda pair: (-pair[1], pair[0]))
def rank_by_euclidean(
query: Sequence[float], catalogue: dict[str, Sequence[float]]
) -> list[tuple[str, float]]:
"""Every item scored by straight-line distance, nearest first."""
scored = [
(label, euclidean_distance(query, vector))
for label, vector in catalogue.items()
]
return sorted(scored, key=lambda pair: (pair[1], pair[0]))
def normalise_all(catalogue: dict[str, Sequence[float]]) -> dict[str, list[float]]:
"""Put every vector in the catalogue on the unit sphere, once."""
return {label: normalise(vector) for label, vector in catalogue.items()}
def mean_absolute_cosine(pairs: Iterable[tuple[Sequence[float], Sequence[float]]]) -> float:
"""The average of |cos| over a collection of vector pairs.
Used in section 6 to measure how nearly orthogonal random vectors become as
the number of dimensions grows.
"""
values = [abs(cosine_similarity(a, b)) for a, b in pairs]
if not values:
raise ValueError("need at least one pair to average over")
return sum(values) / len(values)
examples/test_reference.py (18332 bytes)
"""The reference suite: every claim the lesson makes, asserted on real values.
Run from the LAB DIRECTORY:
.venv/bin/pytest examples -q
Every float comparison declares a tolerance. TOL is 1e-12 for arithmetic that
should agree to the last few bits, and a looser, named tolerance is used where
sampling noise is involved and said so.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from catalogue import (
CATALOGUE,
LONG_ROAST_CHICKEN,
PROJECTION_A,
PROJECTION_B,
QUERIES,
SIGN_CASES,
TRIANGLE_A,
TRIANGLE_B,
TRIANGLE_C,
)
from similarity import (
angle_degrees,
cosine_distance,
cosine_similarity,
dot,
euclidean_distance,
l2_norm,
mean_absolute_cosine,
normalise,
normalise_all,
rank_by_cosine,
rank_by_euclidean,
scalar_projection,
vector_projection,
)
TOL = 1e-12
SAMPLING_TOL = 0.05 # 5%: the dimensionality section samples, so it has noise
# -- The dot product itself --------------------------------------------------
def test_dot_is_the_sum_of_products():
assert dot([1, 2, 3], [4, 5, 6]) == 32.0 # 4 + 10 + 18
def test_dot_matches_numpy_on_every_catalogue_pair():
for first, a in CATALOGUE.items():
for second, b in CATALOGUE.items():
assert dot(a, b) == pytest.approx(float(np.dot(a, b)), abs=TOL), (
first,
second,
)
def test_dot_is_symmetric():
a, b = CATALOGUE["roast-chicken"], CATALOGUE["marathon-plan"]
assert dot(a, b) == dot(b, a)
def test_dot_of_a_vector_with_itself_is_its_length_squared():
for label, vector in CATALOGUE.items():
assert dot(vector, vector) == pytest.approx(l2_norm(vector) ** 2, abs=1e-9), label
def test_dot_refuses_mismatched_lengths():
with pytest.raises(ValueError):
dot([1, 2, 3], [1, 2])
def test_the_geometric_and_algebraic_definitions_agree():
a, b = PROJECTION_A, PROJECTION_B
geometric = l2_norm(a) * l2_norm(b) * cosine_similarity(a, b)
assert dot(a, b) == pytest.approx(geometric, abs=1e-9)
def test_the_projection_triangle_is_three_four_five():
assert l2_norm(PROJECTION_A) == pytest.approx(5.0, abs=TOL)
assert l2_norm(PROJECTION_B) == pytest.approx(10.0, abs=TOL)
assert dot(PROJECTION_A, PROJECTION_B) == pytest.approx(30.0, abs=TOL)
def test_scalar_projection_is_the_shadow_length():
assert scalar_projection(PROJECTION_A, PROJECTION_B) == pytest.approx(6.0, abs=1e-9)
def test_the_shadow_vector_has_the_shadow_length():
shadow = vector_projection(PROJECTION_A, PROJECTION_B)
assert l2_norm(shadow) == pytest.approx(6.0, abs=1e-9)
assert shadow == pytest.approx([3.6, 4.8], abs=1e-9)
def test_projection_is_not_symmetric_even_though_dot_is():
onto_a = scalar_projection(PROJECTION_A, PROJECTION_B)
onto_b = scalar_projection(PROJECTION_B, PROJECTION_A)
assert onto_a == pytest.approx(6.0, abs=1e-9)
assert onto_b == pytest.approx(3.0, abs=1e-9)
assert dot(PROJECTION_A, PROJECTION_B) == dot(PROJECTION_B, PROJECTION_A)
def test_projecting_a_vector_onto_itself_gives_its_length():
for label, vector in CATALOGUE.items():
assert scalar_projection(vector, vector) == pytest.approx(
l2_norm(vector), abs=1e-9
), label
# -- The sign of the dot product ---------------------------------------------
@pytest.mark.parametrize("label,a,b,expected", SIGN_CASES)
def test_the_sign_cases(label, a, b, expected):
value = dot(a, b)
sign = "positive" if value > 0 else ("zero" if value == 0 else "negative")
assert sign == expected, label
@pytest.mark.parametrize("label,a,b,expected", SIGN_CASES)
def test_the_sign_of_the_cosine_matches_the_sign_of_the_dot(label, a, b, expected):
value = dot(a, b)
cos = cosine_similarity(a, b)
assert (value > 0) == (cos > 0), label
assert (value == 0) == (abs(cos) < TOL), label
def test_the_five_angles_are_the_expected_ones():
expected = [0.0, 45.0, 90.0, 135.0, 180.0]
measured = [angle_degrees(a, b) for _, a, b, _ in SIGN_CASES]
assert measured == pytest.approx(expected, abs=1e-9)
def test_perpendicular_vectors_have_a_zero_dot_product():
assert dot([3, 0], [0, 5]) == 0.0
assert cosine_similarity([3, 0], [0, 5]) == pytest.approx(0.0, abs=TOL)
def test_three_pairs_in_the_catalogue_are_exactly_orthogonal():
orthogonal = {
(first, second)
for i, first in enumerate(CATALOGUE)
for second in list(CATALOGUE)[i + 1:]
if dot(CATALOGUE[first], CATALOGUE[second]) == 0
}
assert orthogonal == {
("roast-chicken", "storm-bulletin"),
("slow-cooker-stew", "storm-bulletin"),
("household-budget", "storm-bulletin"),
}
# -- The length confound -----------------------------------------------------
def test_the_doubled_copy_is_every_count_doubled():
assert LONG_ROAST_CHICKEN == [2 * n for n in CATALOGUE["roast-chicken"]]
def test_euclidean_distance_to_the_doubled_copy_is_the_articles_own_length():
short = CATALOGUE["roast-chicken"]
assert euclidean_distance(short, LONG_ROAST_CHICKEN) == pytest.approx(
l2_norm(short), abs=1e-9
)
assert euclidean_distance(short, LONG_ROAST_CHICKEN) == pytest.approx(
math.sqrt(82), abs=1e-9
)
def test_euclidean_calls_the_doubled_copy_further_than_a_different_article():
short = CATALOGUE["roast-chicken"]
to_copy = euclidean_distance(short, LONG_ROAST_CHICKEN)
to_rival = euclidean_distance(short, CATALOGUE["race-day-nutrition"])
assert to_copy > to_rival
assert to_copy == pytest.approx(9.055385, abs=1e-6)
assert to_rival == pytest.approx(8.062258, abs=1e-6)
def test_cosine_calls_the_doubled_copy_identical():
short = CATALOGUE["roast-chicken"]
assert cosine_similarity(short, LONG_ROAST_CHICKEN) == pytest.approx(1.0, abs=TOL)
assert cosine_distance(short, LONG_ROAST_CHICKEN) == pytest.approx(0.0, abs=TOL)
@pytest.mark.parametrize("factor", [0.5, 2, 3, 10, 1000])
def test_scaling_either_vector_by_any_positive_number_leaves_cosine_unchanged(factor):
a, b = CATALOGUE["roast-chicken"], CATALOGUE["race-day-nutrition"]
base = cosine_similarity(a, b)
assert cosine_similarity([factor * x for x in a], b) == pytest.approx(base, abs=TOL)
assert cosine_similarity(a, [factor * x for x in b]) == pytest.approx(base, abs=TOL)
def test_scaling_by_a_negative_number_flips_the_sign():
a, b = CATALOGUE["roast-chicken"], CATALOGUE["race-day-nutrition"]
base = cosine_similarity(a, b)
flipped = cosine_similarity([-x for x in a], b)
assert flipped == pytest.approx(-base, abs=TOL)
# -- Cosine similarity against NumPy -----------------------------------------
def numpy_cosine(a, b) -> float:
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
def test_cosine_matches_numpy_on_every_catalogue_pair():
for first, a in CATALOGUE.items():
for second, b in CATALOGUE.items():
assert cosine_similarity(a, b) == pytest.approx(
numpy_cosine(a, b), abs=TOL
), (first, second)
def test_cosine_is_the_dot_product_of_the_unit_vectors():
for first, a in CATALOGUE.items():
for second, b in CATALOGUE.items():
assert cosine_similarity(a, b) == pytest.approx(
dot(normalise(a), normalise(b)), abs=TOL
), (first, second)
def test_every_cosine_lands_inside_minus_one_to_one():
for a in CATALOGUE.values():
for b in CATALOGUE.values():
value = cosine_similarity(a, b)
assert -1.0 <= value <= 1.0
def test_a_vector_compared_with_itself_is_exactly_one_after_clamping():
for label, vector in CATALOGUE.items():
assert cosine_similarity(vector, vector) == pytest.approx(1.0, abs=1e-12), label
assert cosine_similarity(vector, vector) <= 1.0, label
def test_the_unclamped_formula_really_does_miss_one_on_this_catalogue():
"""The clamp is not defensive decoration: three of six articles miss.
Measured on the authoring machine with Python 3.14.0. The exact set of
articles that miss could differ on another platform's floating point, so
the test asserts the shape of the finding — at least one below and at
least one above — rather than naming them.
"""
below, above, exact = [], [], []
for label, vector in CATALOGUE.items():
length = math.sqrt(sum(x * x for x in vector))
raw = sum(x * y for x, y in zip(vector, vector)) / (length * length)
(below if raw < 1.0 else above if raw > 1.0 else exact).append(label)
assert below, "expected at least one article to round below 1.0"
assert above, "expected at least one article to round above 1.0"
assert len(below) + len(above) + len(exact) == len(CATALOGUE)
for label in above:
vector = CATALOGUE[label]
length = math.sqrt(sum(x * x for x in vector))
raw = sum(x * y for x, y in zip(vector, vector)) / (length * length)
with pytest.raises(ValueError):
math.acos(raw)
def test_cosine_refuses_the_zero_vector():
with pytest.raises(ValueError):
cosine_similarity([0, 0, 0, 0], CATALOGUE["roast-chicken"])
def test_normalise_refuses_the_zero_vector():
with pytest.raises(ValueError):
normalise([0, 0])
def test_the_clamp_keeps_acos_in_its_domain():
# race-day-nutrition compared with itself gives 1.0000000000000002 through
# the unguarded formula, which math.acos refuses. Not invented: found by
# this suite failing while the lab was being written.
vector = CATALOGUE["race-day-nutrition"]
length = math.sqrt(sum(x * x for x in vector))
unclamped = sum(x * y for x, y in zip(vector, vector)) / (length * length)
assert unclamped > 1.0
with pytest.raises(ValueError):
math.acos(unclamped)
assert cosine_similarity(vector, vector) == 1.0
assert angle_degrees(vector, vector) == pytest.approx(0.0, abs=1e-9)
def test_normalised_vectors_all_have_length_one():
for label, unit in normalise_all(CATALOGUE).items():
assert l2_norm(unit) == pytest.approx(1.0, abs=TOL), label
# -- Cosine distance and the metric conditions -------------------------------
def test_cosine_distance_is_one_minus_similarity():
for a in CATALOGUE.values():
for b in CATALOGUE.values():
assert cosine_distance(a, b) == pytest.approx(
1.0 - cosine_similarity(a, b), abs=TOL
)
def test_cosine_distance_runs_from_zero_to_two():
assert cosine_distance([1, 0], [1, 0]) == pytest.approx(0.0, abs=TOL)
assert cosine_distance([1, 0], [0, 1]) == pytest.approx(1.0, abs=TOL)
assert cosine_distance([1, 0], [-1, 0]) == pytest.approx(2.0, abs=TOL)
def test_no_pair_of_count_vectors_exceeds_distance_one():
for a in CATALOGUE.values():
for b in CATALOGUE.values():
assert cosine_distance(a, b) <= 1.0 + TOL
def test_cosine_distance_fails_the_triangle_inequality():
ab = cosine_distance(TRIANGLE_A, TRIANGLE_B)
bc = cosine_distance(TRIANGLE_B, TRIANGLE_C)
ac = cosine_distance(TRIANGLE_A, TRIANGLE_C)
assert ab == pytest.approx(1 - 1 / math.sqrt(2), abs=1e-9)
assert bc == pytest.approx(1 - 1 / math.sqrt(2), abs=1e-9)
assert ac == pytest.approx(1.0, abs=1e-9)
assert ac > ab + bc # the failure, asserted
def test_euclidean_distance_holds_the_triangle_inequality_on_the_same_triple():
for a, b, c in ((TRIANGLE_A, TRIANGLE_B, TRIANGLE_C),):
assert euclidean_distance(a, c) <= euclidean_distance(a, b) + euclidean_distance(
b, c
) + TOL
ua, ub, uc = normalise(a), normalise(b), normalise(c)
assert euclidean_distance(ua, uc) <= euclidean_distance(
ua, ub
) + euclidean_distance(ub, uc) + TOL
def test_cosine_distance_is_zero_for_vectors_that_are_not_equal():
# It fails the identity condition too, and that failure is the useful part.
assert cosine_distance(CATALOGUE["roast-chicken"], LONG_ROAST_CHICKEN) == (
pytest.approx(0.0, abs=TOL)
)
assert CATALOGUE["roast-chicken"] != LONG_ROAST_CHICKEN
def test_cosine_distance_is_symmetric():
for a in CATALOGUE.values():
for b in CATALOGUE.values():
assert cosine_distance(a, b) == pytest.approx(cosine_distance(b, a), abs=TOL)
# -- The ranking equivalence on the unit sphere ------------------------------
def test_the_unit_sphere_identity_holds_on_every_pair():
units = normalise_all(CATALOGUE)
for first, u in units.items():
for second, v in units.items():
predicted = math.sqrt(max(0.0, 2 - 2 * cosine_similarity(u, v)))
assert euclidean_distance(u, v) == pytest.approx(predicted, abs=1e-9), (
first,
second,
)
@pytest.mark.parametrize("query_label", list(CATALOGUE))
def test_normalised_rankings_are_identical_under_both_measures(query_label):
units = normalise_all(CATALOGUE)
query = units[query_label]
by_cosine = [label for label, _ in rank_by_cosine(query, units)]
by_euclid = [label for label, _ in rank_by_euclidean(query, units)]
assert by_cosine == by_euclid
def test_raw_rankings_can_disagree():
raw = dict(CATALOGUE)
raw["roast-chicken (2x)"] = LONG_ROAST_CHICKEN
query = CATALOGUE["roast-chicken"]
by_cosine = [label for label, _ in rank_by_cosine(query, raw)]
by_euclid = [label for label, _ in rank_by_euclidean(query, raw)]
assert by_cosine != by_euclid
def test_distance_on_the_sphere_falls_strictly_as_cosine_rises():
previous = None
for cos in (-1.0, -0.5, 0.0, 0.5, 0.9, 1.0):
distance = math.sqrt(2 - 2 * cos)
if previous is not None:
assert distance < previous
previous = distance
# -- The semantic search -----------------------------------------------------
def test_the_cooking_note_retrieves_roast_chicken():
ranked = rank_by_cosine(QUERIES["roast it"], CATALOGUE)
assert ranked[0][0] == "roast-chicken"
assert ranked[0][1] == pytest.approx(0.993884, abs=1e-6)
assert ranked[1][0] == "slow-cooker-stew"
def test_the_training_query_retrieves_race_day_nutrition_narrowly():
ranked = rank_by_cosine(
QUERIES["training for a race and what to eat"], CATALOGUE
)
assert ranked[0][0] == "race-day-nutrition"
assert ranked[1][0] == "marathon-plan"
# The margin is genuinely small, and the test says so rather than hiding it.
assert 0.002 < ranked[0][1] - ranked[1][1] < 0.003
def test_raw_euclidean_gets_the_cooking_note_wrong():
ranked = rank_by_euclidean(QUERIES["roast it"], CATALOGUE)
assert ranked[0][0] == "slow-cooker-stew"
assert ranked[0][0] != "roast-chicken"
def test_the_raw_dot_product_gets_the_training_query_wrong():
query = QUERIES["training for a race and what to eat"]
by_dot = sorted(CATALOGUE, key=lambda label: (-dot(query, CATALOGUE[label]), label))
assert by_dot[0] == "marathon-plan"
assert dot(query, CATALOGUE["marathon-plan"]) == 45.0
assert dot(query, CATALOGUE["race-day-nutrition"]) == 38.0
@pytest.mark.parametrize("factor", [1, 3, 100, 1000])
def test_the_querys_own_length_changes_nothing(factor):
base = QUERIES["roast it"]
scaled = [factor * x for x in base]
assert [label for label, _ in rank_by_cosine(scaled, CATALOGUE)] == [
label for label, _ in rank_by_cosine(base, CATALOGUE)
]
def test_the_ranking_is_deterministic_under_ties():
# Two articles score exactly 0 against the cooking note. The tie must break
# the same way every run, or the test suite becomes flaky.
ranked = rank_by_cosine(QUERIES["roast it"], CATALOGUE)
tail = [label for label, score in ranked if abs(score) < TOL]
assert tail == ["marathon-plan", "storm-bulletin"]
# -- The curse of dimensionality ---------------------------------------------
def exact_mean_abs_cos(dimension: int) -> float:
return math.exp(
math.lgamma(dimension / 2)
- 0.5 * math.log(math.pi)
- math.lgamma((dimension + 1) / 2)
)
def test_the_exact_formula_reproduces_the_two_hand_checkable_cases():
assert exact_mean_abs_cos(2) == pytest.approx(2 / math.pi, abs=1e-12)
assert exact_mean_abs_cos(3) == pytest.approx(0.5, abs=1e-12)
@pytest.mark.parametrize("dimension", [2, 8, 128, 2048])
def test_measured_mean_absolute_cosine_matches_the_exact_formula(dimension):
rng = np.random.default_rng(103)
a = rng.standard_normal((2000, dimension))
b = rng.standard_normal((2000, dimension))
numerator = np.einsum("ij,ij->i", a, b)
denominator = np.linalg.norm(a, axis=1) * np.linalg.norm(b, axis=1)
measured = float(np.mean(np.abs(numerator / denominator)))
expected = exact_mean_abs_cos(dimension)
assert abs(measured - expected) / expected < SAMPLING_TOL
def test_mean_absolute_cosine_falls_as_dimension_grows():
rng = np.random.default_rng(103)
previous = None
for dimension in (2, 8, 32, 128, 512, 2048):
pairs = [
(rng.standard_normal(dimension), rng.standard_normal(dimension))
for _ in range(300)
]
value = mean_absolute_cosine(pairs)
if previous is not None:
assert value < previous, dimension
previous = value
assert previous < 0.05
def test_the_measurement_is_reproducible_with_the_same_seed():
def run():
rng = np.random.default_rng(103)
a = rng.standard_normal((500, 64))
b = rng.standard_normal((500, 64))
numerator = np.einsum("ij,ij->i", a, b)
denominator = np.linalg.norm(a, axis=1) * np.linalg.norm(b, axis=1)
return float(np.mean(np.abs(numerator / denominator)))
assert run() == run()
def test_distances_concentrate_as_dimension_grows():
rng = np.random.default_rng(104)
ratios = []
for dimension in (2, 8192):
cloud = rng.standard_normal((500, dimension))
query = rng.standard_normal(dimension)
distances = np.linalg.norm(cloud - query, axis=1)
ratios.append(float(distances.max() / distances.min()))
assert ratios[0] > 5.0
assert ratios[1] < 1.5
metadata.yml (2571 bytes)
lesson_id: D103
day: 103
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-103-dot-products-and-similarity
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import numpy; print(numpy.__version__)"
run_commands:
- 'cd examples && ../.venv/bin/python3 01_the_length_confound.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_dot_product_and_sign.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_from_scratch_vs_numpy.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_same_ranking_on_the_sphere.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_not_a_metric.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_semantic_search.py && cd ..'
- 'cd examples && ../.venv/bin/python3 07_curse_of_dimensionality.py && cd ..'
- .venv/bin/pytest examples -q -p no:cacheprovider
- .venv/bin/pytest starter -q -p no:cacheprovider
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- "find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +"
- rm -rf .pytest_cache
- 'rm -rf .venv # optional: removes the lab virtual environment'
- 'git checkout -- starter/ # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-08-16'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 — bash tests/run_tests.sh -> 49 checks, 0 failure(s), exit 0; pytest examples -> 76 passed; pytest starter -> 1 passed, 51 skipped on an untouched checkout, and 52 passed against a fully solved copy of starter/ kept outside the lab. All seven reference scripts exit 0 with every internal assertion holding. Network is needed once to install numpy and pytest; nothing else in the lab opens a socket, and section 7 of the harness greps the sources to prove it. Section 6 re-runs the harness with the ranking-equivalence expectation deliberately inverted and asserts that the re-run exits non-zero and reports exactly one failure, so the suite is demonstrated to be capable of failing rather than merely claimed to be. One finding changed the lab while it was being written: the reference suite failed on an assertion that a vector compared with itself gives exactly 1.0 — three of the six articles miss it, and race-day-nutrition returns 1.0000000000000002 through the unguarded formula, which math.acos refuses. The clamp and the tests around it come from that measured failure, not from anticipation.'
requirements/README.md (5060 bytes)
# Dependencies for the Day 103 lab
Two packages, both free and open source, both installed from the Python
Package Index with `pip`, both running entirely on your own machine.
| Package | Pinned version | Why this lab needs it |
| --- | --- | --- |
| `numpy` | `2.5.2` | Two jobs. It is the independent check that your from-scratch dot product and cosine similarity are right — `numpy.dot` and `numpy.linalg.norm` computed the same values by a different route. And it supplies `numpy.random.default_rng`, the seeded generator that makes the curse-of-dimensionality measurement reproducible. |
| `pytest` | `9.1.1` | The test runner from Days 071–074. Nothing new here except what it is pointed at. |
Nothing else is required. Both suites and all seven reference scripts run on
those two packages plus the standard library. The functions you write in
`starter/similarity.py` use nothing but `math`.
## Why numpy is pinned
Two reasons, and only the second is about correctness.
The measurement in `examples/07_curse_of_dimensionality.py` draws random
numbers. It is seeded with `numpy.random.default_rng(103)`, and NumPy
guarantees that a given generator with a given seed produces the same stream —
a guarantee that holds within a major version and is not promised across one.
The captured numbers in `../expected-output/07-curse-of-dimensionality.txt`
therefore belong to numpy 2.5.2 specifically. On a different version the
*shape* of the result will be identical (mean absolute cosine falling towards
zero as the dimension grows, tracking the exact formula) and individual digits
may move.
The second reason is that the version is *checked* rather than assumed.
Section 1 of `tests/run_tests.sh` reads the installed version and compares it
against `requirements.txt`, so a mismatch is reported at the top of the run
instead of surfacing later as a confusing diff.
The version was read from the installed package rather than guessed:
```bash
.venv/bin/python3 -c "from importlib.metadata import version; print(version('numpy'))"
```
On the authoring machine, on 16 August 2026, that printed `2.5.2`.
## Licences
NumPy is distributed under the BSD 3-Clause licence and pytest under the MIT
licence, each stated on that project's own documentation site. Both are
maintained in the open, cost nothing, and need no account, no key and no
signup — personally or commercially.
## One-time install
From the lab directory:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)"
```
Expect `2.5.2`. Day 43 covered `python3 -m venv` in full; this is the same
pattern. The environment lives in `.venv/` inside the lab, is already excluded
from version control, and can be deleted at any time with `rm -rf .venv`.
## Network
Installing needs the network, once. **Nothing else in this lab does.** No
script opens a socket, reads a URL or contacts a service, and section 7 of
`tests/run_tests.sh` greps every file under `examples/` and `starter/` for the
patterns that would indicate otherwise.
If you are offline and already have NumPy available somewhere, you do not need
the install at all — see the next section.
## Running without a lab-local environment
If NumPy and pytest are already available in an environment you have
activated, the harness will find `pytest` on your `PATH`. You can also point it
at a specific binary:
```bash
PYTEST=/path/to/pytest bash tests/run_tests.sh
```
The harness uses the `python3` that sits beside that `pytest`, because that is
the interpreter NumPy is installed into. If NumPy is not importable from it,
the harness says so and stops rather than skipping checks quietly.
## What you would give up without NumPy
Exercise 1 — the seven functions in `starter/similarity.py` — needs nothing but
the standard library, and you can complete every one of them on a bare
`python3`. What you lose is the checking: the tests compare your answers
against `numpy.dot` and `numpy.linalg.norm`, and "my implementation agrees with
an independent one" is a much stronger statement than "my implementation
returns a number". You would also lose the dimensionality measurement in
section 7, which needs to draw and compare thousands of high-dimensional
vectors quickly. A pure-Python version of that runs, but it takes long enough
to be annoying, which is itself a fair demonstration of why NumPy exists.
## A package this lab describes but does NOT install
`scipy.spatial.distance` provides `cosine`, `euclidean` and about twenty other
distance functions, and it is the tool you would reach for in real work rather
than writing your own. It is **not** in `requirements.txt` and it was **not**
run here, so this lab reproduces no output from it and makes no claim about
its numbers. The lesson describes it from its own documentation and says
plainly that nothing was executed. SciPy is BSD-licensed and free, and
installing it is one line if you want to compare — but then the comparison is
yours, not this lab's.
requirements/requirements.txt (27 bytes)
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (4194 bytes)
# Your brief — Which Question Are You Asking?
Day 99 taught you to measure how far apart two vectors are. Today you find out
that "how far apart" is usually the wrong question about text, write the
measure that asks the right one, and prove three things about it that most
people who use it every day have never checked.
There are two files to edit and one command to run.
## The command
From the **lab directory** — one level up from here:
```bash
.venv/bin/pytest starter -q
```
Anything you have not written yet is **skipped**, not failed. On an untouched
checkout you should see `1 passed, 51 skipped`. That is your running score:
every exercise you finish turns a skip into a pass, and a wrong answer fails
loudly with both numbers printed.
## The files
| File | What you do in it |
| --- | --- |
| `similarity.py` | Write seven functions. Pure Python; `import math` is the only import allowed. |
| `answers.py` | Replace 24 `None` values with predictions you make **before** running anything. |
## Exercise 1 — the toolkit (7 functions)
| # | Function | One-line summary |
| --- | --- | --- |
| 1.1 | `dot` | Multiply component by component, then add. |
| 1.2 | `l2_norm` | `sqrt(dot(a, a))` — a vector's length is the square root of it dotted with itself. |
| 1.3 | `normalise` | Divide by the length. Refuse the zero vector. |
| 1.4 | `euclidean_distance` | The length of the difference. Day 99's measure. |
| 1.5 | `cosine_similarity` | `dot(a, b) / (|a| * |b|)`, clamped to -1..1, refusing the zero vector. |
| 1.6 | `cosine_distance` | `1 - cosine_similarity(a, b)`. |
| 1.7 | `rank_by_cosine` | Score every item, sort best first, break ties by label. |
Three of them have a trap in them, and each trap has its own test:
- **1.3 and 1.5 must refuse the zero vector** with `ValueError`. It has no
direction, so there is no angle to it. Returning `NaN` is the tempting
shortcut; a `NaN` sorts unpredictably and poisons every average downstream.
- **1.5 must clamp.** Three of this lab's six articles, compared with
themselves through the unguarded formula, come out at something other than
exactly 1.0 — one of them at `1.0000000000000002`, which `math.acos` refuses
outright. This is measured on the authoring machine, not hypothetical.
- **1.7 must break ties deterministically.** Two articles score exactly 0.0
against the cooking query. A ranking that orders them differently between
runs makes a test suite that fails at random.
## Exercises 2 to 6 — predict, then check
Twenty-four predictions in `answers.py`, grouped by theme:
| Exercise | Theme | What you are predicting |
| --- | --- | --- |
| 2 | The length confound | Two distances, one cosine, and the general fact behind them |
| 3 | The sign | Three dot products, one angle, and which articles are orthogonal |
| 4 | Not a metric | Two cosine distances, and whether the triangle inequality survives |
| 5 | The unit sphere | The identity `sqrt(2 - 2cos)`, two distances, two ranking questions |
| 6 | Search and the curse | Four retrieval outcomes and one limit |
Write the predictions **first**. That is not a study tip, it is the design of
the exercise: the entire day is about two measures that feel like they should
agree and do not, and the only way to find out whether your intuition is right
is to commit to an answer while it can still be wrong.
Several tests then check that your predictions and your own implementation
agree with **each other**, so a lucky guess with broken code still fails.
## When you are done
`52 passed`. Then read `examples/similarity.py` and compare — your version and
the reference should agree on behaviour, not on wording. Then run the seven
demonstration scripts in `examples/` in order; they take the same numbers much
further than the tests do.
## Rules
- No `numpy` in `similarity.py`. The whole point is that nothing is done for
you; NumPy appears only in the tests, as the independent check.
- Never modify an argument. Return new lists.
- Raise `ValueError`, not a bare `assert`, on nonsense input — NumPy raises
`ValueError` for the same situations, so one `except ValueError` catches your
code and the library alike.
starter/answers.py (6588 bytes)
"""Exercises 2 to 6 — predict first, then let the arithmetic tell you.
Every value below is a prediction you make BEFORE running anything. Replace
each `None` with your answer, then check from the lab directory:
.venv/bin/pytest starter -q
Each prediction still set to None is SKIPPED rather than failed, so the run is
a running score. A wrong prediction fails with both numbers printed.
Predicting first matters here more than usual, because the whole day is about
two measures that feel like they should agree and do not. The only way to find
out whether your intuition is right is to commit to an answer while it can
still be wrong.
The data everything below refers to — the same six invented articles as Day 99,
described by how often each one talks about four things:
cooking running money weather
roast-chicken 9 0 1 0
slow-cooker-stew 8 0 2 0
marathon-plan 0 9 1 2
race-day-nutrition 4 6 3 0
household-budget 1 0 9 0
storm-bulletin 0 1 0 9
and one more: roast-chicken written at twice the length, every count doubled,
roast-chicken (2x) 18 0 2 0
Float answers are compared with a tolerance of 1e-6, so four decimal places is
plenty. Where a whole number is exact, give the whole number.
"""
# ---------------------------------------------------------------------------
# Exercise 2 — the length confound
# ---------------------------------------------------------------------------
# 2.1 The Euclidean distance between roast-chicken and its doubled copy.
# Work it out on paper: subtract, square, add, square-root.
# [9, 0, 1, 0] - [18, 0, 2, 0] = ?
DISTANCE_TO_DOUBLED_COPY = None
# 2.2 The Euclidean distance between roast-chicken and race-day-nutrition.
DISTANCE_TO_RACE_DAY = None
# 2.3 Given those two numbers: does Euclidean distance place the doubled copy
# of roast-chicken FURTHER from roast-chicken than race-day-nutrition is?
# True or False.
DOUBLED_COPY_IS_FURTHER = None
# 2.4 The cosine similarity between roast-chicken and its doubled copy.
# You should be able to answer this one without any arithmetic at all.
COSINE_TO_DOUBLED_COPY = None
# 2.5 There is a tidy general fact behind 2.1. For any vector v, the distance
# between v and 2v equals one of the following. Which? Answer with the
# string exactly as written: "0", "|v|", "2|v|", or "|v| squared".
DISTANCE_BETWEEN_V_AND_2V = None
# ---------------------------------------------------------------------------
# Exercise 3 — the sign of the dot product
# ---------------------------------------------------------------------------
# 3.1 [3, 0] dot [6, 0] — a single number.
DOT_SAME_DIRECTION = None
# 3.2 [3, 0] dot [0, 5].
DOT_PERPENDICULAR = None
# 3.3 [3, 0] dot [-6, 0].
DOT_OPPOSITE = None
# 3.4 The angle in degrees between [3, 0] and [1, 1]. A whole number.
ANGLE_45_CASE = None
# 3.5 Which articles in the table above have a dot product of exactly 0 with
# storm-bulletin? A dot product of 0 means the two share no vocabulary at
# all — wherever one has a count, the other has none. Answer with a list
# of the article names, sorted alphabetically. (There is more than one.)
ORTHOGONAL_TO_STORM_BULLETIN = None
# ---------------------------------------------------------------------------
# Exercise 4 — cosine distance is not a metric
# ---------------------------------------------------------------------------
#
# Three vectors in two dimensions:
#
# a = [1, 0] b = [1, 1] c = [0, 1]
#
# 4.1 The cosine distance from a to b, to four decimal places.
D_A_TO_B = None
# 4.2 The cosine distance from a to c.
D_A_TO_C = None
# 4.3 The triangle inequality says d(a, c) <= d(a, b) + d(b, c). Does it hold
# for these three under COSINE distance? True or False.
TRIANGLE_HOLDS_FOR_COSINE = None
# 4.4 Does it hold for the same three under EUCLIDEAN distance? True or False.
TRIANGLE_HOLDS_FOR_EUCLIDEAN = None
# ---------------------------------------------------------------------------
# Exercise 5 — the same ranking on the unit sphere
# ---------------------------------------------------------------------------
#
# For two UNIT vectors u and v:
#
# |u - v|^2 = (u - v) dot (u - v)
# = (u dot u) - 2 (u dot v) + (v dot v)
# = 1 - 2 cos(theta) + 1
#
# 5.1 Complete the identity. The Euclidean distance between two unit vectors
# equals sqrt of what expression in cos? Answer with the string exactly as
# written: "2 - 2cos", "1 - cos", "2 + 2cos", or "1 - 2cos".
UNIT_DISTANCE_FORMULA = None
# 5.2 Two unit vectors at 90 degrees. How far apart are they, to four decimal
# places?
DISTANCE_BETWEEN_PERPENDICULAR_UNIT_VECTORS = None
# 5.3 Two unit vectors pointing in opposite directions. How far apart?
DISTANCE_BETWEEN_OPPOSITE_UNIT_VECTORS = None
# 5.4 Rank the catalogue against a normalised roast-chicken, once by cosine
# similarity descending and once by Euclidean distance ascending. Are the
# two orderings identical? True or False.
NORMALISED_RANKINGS_MATCH = None
# 5.5 Do the same on the RAW vectors, with the doubled copy included in the
# catalogue. Are those two orderings identical? True or False.
RAW_RANKINGS_MATCH = None
# ---------------------------------------------------------------------------
# Exercise 6 — the search, and the curse
# ---------------------------------------------------------------------------
# 6.1 The query "roast it" becomes [1, 0, 0, 0]. Which article does cosine
# similarity rank first? A string, exactly as spelled in the table.
TOP_HIT_FOR_ROAST_IT = None
# 6.2 The same query, ranked by RAW Euclidean distance instead. Which article
# comes first? (Day 99 answered this one; it is not the same answer.)
NEAREST_BY_RAW_EUCLIDEAN_FOR_ROAST_IT = None
# 6.3 The query "training for a race and what to eat" becomes [2, 5, 0, 0].
# Which article does cosine rank first?
TOP_HIT_FOR_TRAINING = None
# 6.4 Multiply that query by 100, giving [200, 500, 0, 0]. Does the cosine
# ranking change? True or False.
SCALING_THE_QUERY_CHANGES_THE_RANKING = None
# 6.5 Two random vectors in a high-dimensional space. As the number of
# dimensions grows, the average absolute cosine similarity between them
# tends towards which value? Answer with a number: 0, 0.5, or 1.
MEAN_ABS_COSINE_TENDS_TOWARDS = None
starter/conftest.py (1281 bytes)
"""Make this directory's own similarity.py the one its tests import.
Both `examples/` and `starter/` contain a module called `similarity`, and
pytest imports test files by putting their directory on `sys.path`. Without
this file, running `pytest` across both directories at once would import
whichever `similarity` was seen first and reuse it for the other suite — so
these starter tests would silently pass against the reference solution instead
of skipping. That is a wrong answer with a green tick on it, which is the worst
kind, and Day 100 shipped a version of this bug before it was caught.
So: put this directory first on the import path, and drop any already-imported
`similarity` or `answers` that came from somewhere else.
Section 4 of tests/run_tests.sh checks that this still works, by comparing the
skip count from `pytest starter` against the skip count from a combined run.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("similarity", "catalogue", "answers"):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
starter/similarity.py (6072 bytes)
"""Exercise 1 — write the similarity toolkit yourself, in pure Python.
Seven functions, none longer than four lines. Together they are the entire
arithmetic of semantic search, and after writing them you will never again be
unsure what a vector database is doing when it says "cosine".
Check your work as you go, from the LAB DIRECTORY (one level up from here):
.venv/bin/pytest starter -q
Every test for a function you have not written yet is SKIPPED, not failed, so
the output is a running score rather than a wall of red. When all of them pass,
compare your file with examples/similarity.py — they should agree on behaviour,
not necessarily on wording.
Rules for all seven:
* pure Python only — `import math` is allowed and nothing else. The point of
the exercise is that nothing here is done for you;
* never modify the arguments;
* raise ValueError, not a bare assert, when the input makes no sense. NumPy
raises ValueError for the same situations, so one `except ValueError` will
catch your code and the library alike.
"""
from __future__ import annotations
import math
def _check_same_length(a, b) -> None:
"""Written for you: refuse two vectors of different lengths, loudly.
Every function below that takes two vectors should call this first.
"""
if len(a) != len(b):
raise ValueError(
f"vectors must have the same number of components: "
f"got {len(a)} and {len(b)}"
)
# ------------------------------------------------------------------ 1.1 --
def dot(a, b):
"""EXERCISE 1.1 — the dot product: multiply component by component, then add.
dot([1, 2, 3], [4, 5, 6]) = 1*4 + 2*5 + 3*6 = 4 + 10 + 18 = 32.0
Return a float, not a list — the dot product of two vectors is a single
number. Call _check_same_length first.
Hint: sum(x * y for x, y in zip(a, b)), wrapped in float().
"""
raise NotImplementedError("write dot")
# ------------------------------------------------------------------ 1.2 --
def l2_norm(a):
"""EXERCISE 1.2 — the length of a vector: sqrt of the sum of its squares.
l2_norm([3, 4]) = sqrt(9 + 16) = sqrt(25) = 5.0
Day 99 built this with Pythagoras. Today there is a shorter route worth
noticing and using: a vector's length is the square root of the vector
dotted with ITSELF.
Hint: math.sqrt(dot(a, a)). One line, and it reuses 1.1.
"""
raise NotImplementedError("write l2_norm")
# ------------------------------------------------------------------ 1.3 --
def normalise(a):
"""EXERCISE 1.3 — the unit vector: same direction, length exactly 1.
normalise([3, 4]) = [0.6, 0.8]
Divide every component by the vector's own length, and return a NEW list.
The zero vector has no direction, so there is no unit vector pointing the
same way. Raise ValueError with a message saying so, rather than letting
Python raise ZeroDivisionError from somewhere deeper.
"""
raise NotImplementedError("write normalise")
# ------------------------------------------------------------------ 1.4 --
def euclidean_distance(a, b):
"""EXERCISE 1.4 — straight-line distance: the length of the difference.
euclidean_distance([9, 0, 1, 0], [8, 0, 2, 0])
= sqrt(1 + 0 + 1 + 0) = sqrt(2) = 1.41421...
This is Day 99's measure, and today's job is to know when it is the wrong
one. Call _check_same_length first.
"""
raise NotImplementedError("write euclidean_distance")
# ------------------------------------------------------------------ 1.5 --
def cosine_similarity(a, b):
"""EXERCISE 1.5 — the cosine of the angle between them, from -1 to 1.
a dot b = |a| |b| cos(theta) so cos(theta) = (a dot b) / (|a| |b|)
Three things this function must get right, and each of them is asserted by
a separate test:
1. Divide by BOTH lengths. That is what makes the result magnitude-free:
doubling either vector must not change the answer at all.
2. Raise ValueError if either vector is the zero vector. Returning NaN
is the tempting shortcut and it is a bad one — a NaN sorts
unpredictably and quietly ruins every average it touches.
3. Clamp the result into the range -1 to 1 before returning it. Floating
point can hand you 1.0000000000000002 for a vector compared with
itself — three of this lab's six articles miss exact 1.0 — and
math.acos of anything above 1.0 raises ValueError.
Hint for the clamp: max(-1.0, min(1.0, value)).
"""
raise NotImplementedError("write cosine_similarity")
# ------------------------------------------------------------------ 1.6 --
def cosine_distance(a, b):
"""EXERCISE 1.6 — one minus the cosine similarity.
cosine_distance(a, b) = 1 - cosine_similarity(a, b)
Zero when the two point the same way, 1 when they are perpendicular, 2
when they point in opposite directions. One line, reusing 1.5.
Remember what exercise 3 will make you prove about it: despite the name,
this is not a metric.
"""
raise NotImplementedError("write cosine_distance")
# ------------------------------------------------------------------ 1.7 --
def rank_by_cosine(query, catalogue):
"""EXERCISE 1.7 — the search itself. Score everything, best first.
`catalogue` is a dict of {label: vector}. Return a list of
(label, similarity) pairs sorted so the highest similarity comes first.
Break ties by label, alphabetically. That matters more than it looks: two
articles in this lab score exactly 0.0 against the cooking query, and a
ranking that puts them in a different order on different runs makes a test
suite that fails at random.
Hint: build the list of pairs, then
sorted(pairs, key=lambda pair: (-pair[1], pair[0]))
The minus sign sorts the score downwards while the label still sorts
upwards, which is the whole trick.
"""
raise NotImplementedError("write rank_by_cosine")
starter/test_starter.py (12795 bytes)
"""Your running score. Run from the LAB DIRECTORY:
.venv/bin/pytest starter -q
Anything you have not written yet is SKIPPED, not failed. A skip means "not
attempted"; a failure means "attempted and wrong", and the failure prints both
your answer and the real one.
Float comparisons use a tolerance of TOL, stated below, because floating-point
equality is a trap (Day 70) and because this lab measured three of six articles
missing exact 1.0 when compared with themselves.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
import answers
from similarity import (
cosine_distance,
cosine_similarity,
dot,
euclidean_distance,
l2_norm,
normalise,
rank_by_cosine,
)
TOL = 1e-6
CATALOGUE = {
"roast-chicken": [9, 0, 1, 0],
"slow-cooker-stew": [8, 0, 2, 0],
"marathon-plan": [0, 9, 1, 2],
"race-day-nutrition": [4, 6, 3, 0],
"household-budget": [1, 0, 9, 0],
"storm-bulletin": [0, 1, 0, 9],
}
LONG_ROAST_CHICKEN = [18, 0, 2, 0]
TRIANGLE_A = [1, 0]
TRIANGLE_B = [1, 1]
TRIANGLE_C = [0, 1]
QUERY_ROAST = [1, 0, 0, 0]
QUERY_TRAINING = [2, 5, 0, 0]
def written(fn, *args, **kwargs):
"""Run part of your toolkit, or skip the test if it is not written yet."""
try:
return fn(*args, **kwargs)
except NotImplementedError as exc:
pytest.skip(f"not written yet: {exc}")
def predicted(name):
"""Read one prediction from answers.py, or skip if it is still None."""
value = getattr(answers, name)
if value is None:
pytest.skip(f"answers.{name} is still unanswered")
return value
# -- Exercise 0: the environment ---------------------------------------------
def test_0_the_environment_is_ready():
"""Always passes once the install worked. Everything below is your work."""
assert np.__version__, "numpy is importable"
assert math.isclose(np.linalg.norm([3, 4]), 5.0), "numpy agrees that 3-4-5 works"
# -- Exercise 1: your similarity toolkit -------------------------------------
def test_1_1_dot_is_the_sum_of_products():
assert written(dot, [1, 2, 3], [4, 5, 6]) == pytest.approx(32.0, abs=TOL)
assert dot([9, 0, 1, 0], [8, 0, 2, 0]) == pytest.approx(74.0, abs=TOL)
def test_1_1_dot_matches_numpy_on_every_catalogue_pair():
written(dot, [1], [1])
for first, a in CATALOGUE.items():
for second, b in CATALOGUE.items():
assert dot(a, b) == pytest.approx(float(np.dot(a, b)), abs=TOL), (
first,
second,
)
def test_1_1_dot_refuses_mismatched_lengths():
written(dot, [1], [1])
with pytest.raises(ValueError):
dot([1, 2, 3], [1, 2])
def test_1_2_l2_norm():
assert written(l2_norm, [3, 4]) == pytest.approx(5.0, abs=TOL)
assert l2_norm([9, 0, 1, 0]) == pytest.approx(math.sqrt(82), abs=TOL)
def test_1_2_l2_norm_matches_numpy():
written(l2_norm, [1])
for label, vector in CATALOGUE.items():
assert l2_norm(vector) == pytest.approx(
float(np.linalg.norm(vector)), abs=TOL
), label
def test_1_3_normalise_gives_length_one():
assert written(normalise, [3, 4]) == pytest.approx([0.6, 0.8], abs=TOL)
for label, vector in CATALOGUE.items():
assert l2_norm(normalise(vector)) == pytest.approx(1.0, abs=TOL), label
def test_1_3_normalise_does_not_modify_its_argument():
vector = [3, 4]
written(normalise, vector)
assert vector == [3, 4], "normalise must return a NEW list"
def test_1_3_normalise_refuses_the_zero_vector():
written(normalise, [3, 4])
with pytest.raises(ValueError):
normalise([0, 0, 0, 0])
def test_1_4_euclidean_distance():
assert written(
euclidean_distance, [9, 0, 1, 0], [8, 0, 2, 0]
) == pytest.approx(math.sqrt(2), abs=TOL)
assert euclidean_distance([0, 0], [3, 4]) == pytest.approx(5.0, abs=TOL)
def test_1_4_euclidean_distance_matches_numpy():
written(euclidean_distance, [1], [1])
for first, a in CATALOGUE.items():
for second, b in CATALOGUE.items():
expected = float(np.linalg.norm(np.array(a) - np.array(b)))
assert euclidean_distance(a, b) == pytest.approx(expected, abs=TOL), (
first,
second,
)
def test_1_5_cosine_similarity_basic_cases():
assert written(cosine_similarity, [1, 0], [1, 0]) == pytest.approx(1.0, abs=TOL)
assert cosine_similarity([1, 0], [0, 1]) == pytest.approx(0.0, abs=TOL)
assert cosine_similarity([1, 0], [-1, 0]) == pytest.approx(-1.0, abs=TOL)
assert cosine_similarity([1, 0], [1, 1]) == pytest.approx(
1 / math.sqrt(2), abs=TOL
)
def test_1_5_cosine_similarity_matches_numpy():
written(cosine_similarity, [1, 0], [1, 0])
for first, a in CATALOGUE.items():
for second, b in CATALOGUE.items():
expected = float(
np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
)
assert cosine_similarity(a, b) == pytest.approx(expected, abs=TOL), (
first,
second,
)
@pytest.mark.parametrize("factor", [0.5, 2, 3, 100])
def test_1_5_cosine_ignores_magnitude(factor):
a, b = CATALOGUE["roast-chicken"], CATALOGUE["race-day-nutrition"]
base = written(cosine_similarity, a, b)
assert cosine_similarity([factor * x for x in a], b) == pytest.approx(
base, abs=TOL
)
assert cosine_similarity(a, [factor * x for x in b]) == pytest.approx(
base, abs=TOL
)
def test_1_5_cosine_refuses_the_zero_vector():
written(cosine_similarity, [1, 0], [1, 0])
with pytest.raises(ValueError):
cosine_similarity([0, 0, 0, 0], CATALOGUE["roast-chicken"])
def test_1_5_cosine_is_clamped_so_acos_never_raises():
"""The clamp is load-bearing: without it this catalogue breaks acos."""
written(cosine_similarity, [1, 0], [1, 0])
for label, vector in CATALOGUE.items():
value = cosine_similarity(vector, vector)
assert -1.0 <= value <= 1.0, label
math.acos(value) # raises ValueError if the clamp is missing
def test_1_6_cosine_distance():
assert written(cosine_distance, [1, 0], [1, 0]) == pytest.approx(0.0, abs=TOL)
assert cosine_distance([1, 0], [0, 1]) == pytest.approx(1.0, abs=TOL)
assert cosine_distance([1, 0], [-1, 0]) == pytest.approx(2.0, abs=TOL)
def test_1_7_rank_by_cosine_orders_best_first():
ranked = written(rank_by_cosine, QUERY_ROAST, CATALOGUE)
assert [label for label, _ in ranked][:2] == [
"roast-chicken",
"slow-cooker-stew",
]
scores = [score for _, score in ranked]
assert scores == sorted(scores, reverse=True)
def test_1_7_rank_by_cosine_breaks_ties_alphabetically():
ranked = written(rank_by_cosine, QUERY_ROAST, CATALOGUE)
zeros = [label for label, score in ranked if abs(score) < TOL]
assert zeros == ["marathon-plan", "storm-bulletin"]
def test_1_7_rank_by_cosine_returns_every_item():
ranked = written(rank_by_cosine, QUERY_TRAINING, CATALOGUE)
assert sorted(label for label, _ in ranked) == sorted(CATALOGUE)
# -- Exercise 2: the length confound -----------------------------------------
def test_2_1_distance_to_the_doubled_copy():
assert predicted("DISTANCE_TO_DOUBLED_COPY") == pytest.approx(
math.sqrt(82), abs=1e-4
)
def test_2_2_distance_to_race_day():
assert predicted("DISTANCE_TO_RACE_DAY") == pytest.approx(math.sqrt(65), abs=1e-4)
def test_2_3_the_doubled_copy_really_is_further():
assert predicted("DOUBLED_COPY_IS_FURTHER") is True
def test_2_4_cosine_to_the_doubled_copy():
assert predicted("COSINE_TO_DOUBLED_COPY") == pytest.approx(1.0, abs=1e-4)
def test_2_5_the_general_fact():
assert predicted("DISTANCE_BETWEEN_V_AND_2V") == "|v|"
def test_2_the_predictions_match_your_own_code():
"""Your predictions and your implementation must agree with each other."""
written(euclidean_distance, [1], [1])
written(cosine_similarity, [1, 0], [1, 0])
short = CATALOGUE["roast-chicken"]
assert euclidean_distance(short, LONG_ROAST_CHICKEN) == pytest.approx(
predicted("DISTANCE_TO_DOUBLED_COPY"), abs=1e-4
)
assert cosine_similarity(short, LONG_ROAST_CHICKEN) == pytest.approx(
predicted("COSINE_TO_DOUBLED_COPY"), abs=1e-4
)
# -- Exercise 3: the sign ----------------------------------------------------
def test_3_1_same_direction():
assert predicted("DOT_SAME_DIRECTION") == pytest.approx(18.0, abs=TOL)
def test_3_2_perpendicular():
assert predicted("DOT_PERPENDICULAR") == pytest.approx(0.0, abs=TOL)
def test_3_3_opposite():
assert predicted("DOT_OPPOSITE") == pytest.approx(-18.0, abs=TOL)
def test_3_4_the_45_degree_case():
assert predicted("ANGLE_45_CASE") == pytest.approx(45.0, abs=0.5)
def test_3_5_orthogonal_to_storm_bulletin():
assert predicted("ORTHOGONAL_TO_STORM_BULLETIN") == [
"household-budget",
"roast-chicken",
"slow-cooker-stew",
]
# -- Exercise 4: not a metric ------------------------------------------------
def test_4_1_cosine_distance_a_to_b():
assert predicted("D_A_TO_B") == pytest.approx(1 - 1 / math.sqrt(2), abs=1e-4)
def test_4_2_cosine_distance_a_to_c():
assert predicted("D_A_TO_C") == pytest.approx(1.0, abs=1e-4)
def test_4_3_the_triangle_inequality_fails_for_cosine():
assert predicted("TRIANGLE_HOLDS_FOR_COSINE") is False
def test_4_4_the_triangle_inequality_holds_for_euclidean():
assert predicted("TRIANGLE_HOLDS_FOR_EUCLIDEAN") is True
def test_4_your_own_code_reproduces_the_failure():
written(cosine_distance, [1, 0], [1, 0])
ab = cosine_distance(TRIANGLE_A, TRIANGLE_B)
bc = cosine_distance(TRIANGLE_B, TRIANGLE_C)
ac = cosine_distance(TRIANGLE_A, TRIANGLE_C)
assert ac > ab + bc, "cosine distance should fail the triangle inequality here"
# -- Exercise 5: the same ranking on the sphere ------------------------------
def test_5_1_the_identity():
assert predicted("UNIT_DISTANCE_FORMULA") == "2 - 2cos"
def test_5_2_perpendicular_unit_vectors():
assert predicted(
"DISTANCE_BETWEEN_PERPENDICULAR_UNIT_VECTORS"
) == pytest.approx(math.sqrt(2), abs=1e-4)
def test_5_3_opposite_unit_vectors():
assert predicted("DISTANCE_BETWEEN_OPPOSITE_UNIT_VECTORS") == pytest.approx(
2.0, abs=1e-4
)
def test_5_4_normalised_rankings_match():
assert predicted("NORMALISED_RANKINGS_MATCH") is True
def test_5_5_raw_rankings_do_not():
assert predicted("RAW_RANKINGS_MATCH") is False
def test_5_your_own_code_proves_the_ranking_equivalence():
written(normalise, [3, 4])
written(cosine_similarity, [1, 0], [1, 0])
written(euclidean_distance, [1], [1])
units = {label: normalise(v) for label, v in CATALOGUE.items()}
query = units["roast-chicken"]
by_cosine = sorted(units, key=lambda k: (-cosine_similarity(query, units[k]), k))
by_euclid = sorted(units, key=lambda k: (euclidean_distance(query, units[k]), k))
assert by_cosine == by_euclid
# -- Exercise 6: the search, and the curse -----------------------------------
def test_6_1_top_hit_for_the_cooking_note():
assert predicted("TOP_HIT_FOR_ROAST_IT") == "roast-chicken"
def test_6_2_raw_euclidean_picks_a_different_article():
assert predicted("NEAREST_BY_RAW_EUCLIDEAN_FOR_ROAST_IT") == "slow-cooker-stew"
def test_6_3_top_hit_for_the_training_query():
assert predicted("TOP_HIT_FOR_TRAINING") == "race-day-nutrition"
def test_6_4_scaling_the_query_changes_nothing():
assert predicted("SCALING_THE_QUERY_CHANGES_THE_RANKING") is False
def test_6_5_high_dimensional_cosine_tends_to_zero():
assert predicted("MEAN_ABS_COSINE_TENDS_TOWARDS") == 0
def test_6_your_own_search_finds_the_predicted_articles():
ranked = written(rank_by_cosine, QUERY_ROAST, CATALOGUE)
assert ranked[0][0] == predicted("TOP_HIT_FOR_ROAST_IT")
training = rank_by_cosine(QUERY_TRAINING, CATALOGUE)
assert training[0][0] == predicted("TOP_HIT_FOR_TRAINING")
def test_6_your_own_code_measures_the_curse():
"""Generate random pairs at growing dimension and watch |cos| collapse."""
written(cosine_similarity, [1, 0], [1, 0])
rng = np.random.default_rng(103)
previous = None
for dimension in (2, 32, 512):
values = [
abs(
cosine_similarity(
rng.standard_normal(dimension).tolist(),
rng.standard_normal(dimension).tolist(),
)
)
for _ in range(200)
]
mean = sum(values) / len(values)
if previous is not None:
assert mean < previous, dimension
previous = mean
assert previous < 0.1
tests/run_tests.sh (18274 bytes)
#!/usr/bin/env bash
# Tests for the Day 103 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves seven specific claims the lesson makes, and it proves each
# one by running code and reading real values rather than by reading source:
#
# * Euclidean distance calls an article's own doubled copy FURTHER away than
# a different article is, while cosine similarity calls it identical —
# the day's motivating failure, computed, not asserted;
# * the from-scratch dot product, cosine similarity and cosine distance
# agree with NumPy on every pair in the catalogue, to a stated tolerance;
# * the sign of the dot product tracks the angle: positive under 90 degrees,
# zero at exactly 90, negative above;
# * on NORMALISED vectors, ranking by cosine and ranking by Euclidean
# distance produce the identical order — and on raw vectors they do not;
# * cosine distance fails the triangle inequality on a concrete triple;
# * the semantic search returns the expected top hit for two queries, and
# the query's own magnitude changes nothing;
# * mean absolute cosine similarity between random vectors falls towards
# zero as the dimension grows, from a seeded generator.
#
# Everything runs offline. Nothing binds a port, nothing writes outside the
# lab, nothing needs a key. Deterministic, non-interactive, exits 0 only if
# every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Bytecode left by an EARLIER command is not this run's litter. The README
# documents `pytest starter -q`, and running it writes .pyc files that would
# then fail the cleanliness check at the end of this script -- failing the
# reader for following the instructions. Clearing them here makes that final
# check measure what it claims to: what THIS run left behind. `.venv` is
# untouched, because the packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
check_eq() {
# check_eq <label> <expected> <actual>
if [ "$2" = "$3" ]; then
check "$1" "yes"
else
check "$1 (expected [$2], got [$3])" "no"
fi
}
# Resolve pytest: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping checks.
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
echo "FAIL: pytest not found." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this suite at an existing pytest:" >&2
echo " PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
exit 1
}
# The Python that owns that pytest is the one with numpy installed.
python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
echo "FAIL: python3 not found on PATH." >&2
exit 1
fi
if ! "${python_bin}" -c "import numpy" >/dev/null 2>&1; then
echo "FAIL: numpy is not importable from ${python_bin}." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
exit 1
fi
echo "Day 103 — Which Question Are You Asking?"
echo
# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------
versions="$("${python_bin}" - <<'PY'
import platform
import sys
from importlib.metadata import version
print(f"python {platform.python_version()}")
for name in ("numpy", "pytest"):
print(f"{name:<8} {version(name)}")
print(f"platform {platform.platform()}")
print(f"exe {sys.executable.rsplit('/', 3)[-1]}")
PY
)"
echo "${versions}" | sed 's/^/ /'
pinned_numpy="$(grep -E '^numpy==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed_numpy="$("${python_bin}" -c "from importlib.metadata import version; print(version('numpy'))")"
check_eq "installed numpy matches requirements.txt" "${pinned_numpy}" "${installed_numpy}"
major="$("${python_bin}" -c "import numpy; print(numpy.__version__.split('.')[0])")"
check_eq "numpy is version 2 or later (numpy.random.default_rng and the 2.x repr)" "2" "${major}"
# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------
for script in 01_the_length_confound 02_dot_product_and_sign \
03_from_scratch_vs_numpy 04_same_ranking_on_the_sphere \
05_not_a_metric 06_semantic_search 07_curse_of_dimensionality; do
out="$(cd "${lab_dir}/examples" && "${python_bin}" "${script}.py" 2>&1)"
status=$?
if [ "${status}" -ne 0 ]; then
check "${script}.py exits 0" "no"
echo "${out}" | tail -5 | sed 's/^/ /'
else
check "${script}.py exits 0" "yes"
fi
case "${out}" in
*"${script}.py: every assertion held."*)
check "${script}.py reports every assertion held" "yes" ;;
*) check "${script}.py reports every assertion held" "no" ;;
esac
done
# --------------------------------------------------------------------------
echo
echo "3. The reference pytest suite: real values, real rankings"
# --------------------------------------------------------------------------
ref_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
ref_status=$?
echo "${ref_out}" | tail -3 | sed 's/^/ /'
if [ "${ref_status}" -eq 0 ]; then
check "pytest examples exits 0" "yes"
else
check "pytest examples exits 0" "no"
fi
case "${ref_out}" in
*" failed"*) check "no test in the reference suite failed" "no" ;;
*) check "no test in the reference suite failed" "yes" ;;
esac
ref_passed="$(printf '%s\n' "${ref_out}" | grep -o '[0-9][0-9]* passed' | head -1 | cut -d' ' -f1)"
if [ "${ref_passed:-0}" -ge 70 ]; then
check "the reference suite ran at least 70 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 70 tests (ran ${ref_passed:-0})" "no"
fi
# --------------------------------------------------------------------------
echo
echo "4. The starter suite skips unattempted work instead of failing it"
# --------------------------------------------------------------------------
start_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
start_status=$?
echo "${start_out}" | tail -3 | sed 's/^/ /'
if [ "${start_status}" -eq 0 ]; then
check "pytest starter exits 0 on an untouched checkout" "yes"
else
check "pytest starter exits 0 on an untouched checkout" "no"
fi
case "${start_out}" in
*" failed"*) check "the starter suite reports no failures" "no" ;;
*) check "the starter suite reports no failures" "yes" ;;
esac
case "${start_out}" in
*skipped*) check "unwritten exercises are reported as skipped, not passed" "yes" ;;
*) check "unwritten exercises are reported as skipped, not passed" "no" ;;
esac
# The import guard. Both directories contain a module called `similarity`, and
# pytest imports test files by putting their directory on sys.path — so
# collecting both suites at once would otherwise let the starter tests import
# the REFERENCE solution and report unwritten exercises as passing. Each
# directory's conftest.py prevents that. This check proves it still does:
# across both suites, the skip count must be unchanged.
both_out="$(cd "${lab_dir}" && "${pytest_bin}" -q -p no:cacheprovider 2>&1)"
start_skipped="$(printf '%s\n' "${start_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
both_skipped="$(printf '%s\n' "${both_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
check_eq "collecting both suites at once does not turn skips into passes" \
"${start_skipped:-none}" "${both_skipped:-none}"
# --------------------------------------------------------------------------
echo
echo "5. The lesson's claims, checked one value at a time"
# --------------------------------------------------------------------------
facts="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY'
import math
import numpy as np
from catalogue import CATALOGUE, LONG_ROAST_CHICKEN, QUERIES, TRIANGLE_A, TRIANGLE_B, TRIANGLE_C
from similarity import (
angle_degrees,
cosine_distance,
cosine_similarity,
dot,
euclidean_distance,
normalise,
normalise_all,
rank_by_cosine,
rank_by_euclidean,
)
short = CATALOGUE["roast-chicken"]
rival = CATALOGUE["race-day-nutrition"]
# The confound.
print("dist_to_doubled", f"{euclidean_distance(short, LONG_ROAST_CHICKEN):.4f}")
print("dist_to_rival", f"{euclidean_distance(short, rival):.4f}")
print("confound", euclidean_distance(short, LONG_ROAST_CHICKEN) > euclidean_distance(short, rival))
print("cos_to_doubled", f"{cosine_similarity(short, LONG_ROAST_CHICKEN):.10f}")
# Agreement with NumPy across every pair.
worst = 0.0
for a in CATALOGUE.values():
for b in CATALOGUE.values():
theirs = float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
worst = max(worst, abs(cosine_similarity(a, b) - theirs))
print("numpy_agreement", worst < 1e-12)
# The signs.
print("signs", int(dot([3, 0], [6, 0])), int(dot([3, 0], [0, 5])), int(dot([3, 0], [-6, 0])))
print("angles", f"{angle_degrees([3, 0], [1, 1]):.2f}", f"{angle_degrees([3, 0], [-2, 2]):.2f}")
# The ranking equivalence.
units = normalise_all(CATALOGUE)
q = units["roast-chicken"]
by_cos = [k for k, _ in rank_by_cosine(q, units)]
by_euc = [k for k, _ in rank_by_euclidean(q, units)]
print("normalised_orders_match", by_cos == by_euc)
raw = dict(CATALOGUE)
raw["roast-chicken (2x)"] = LONG_ROAST_CHICKEN
raw_cos = [k for k, _ in rank_by_cosine(short, raw)]
raw_euc = [k for k, _ in rank_by_euclidean(short, raw)]
print("raw_orders_match", raw_cos == raw_euc)
# The identity behind it.
u, v = units["roast-chicken"], units["marathon-plan"]
print("sphere_identity", abs(euclidean_distance(u, v) - math.sqrt(2 - 2 * cosine_similarity(u, v))) < 1e-9)
# The triangle inequality.
ab = cosine_distance(TRIANGLE_A, TRIANGLE_B)
bc = cosine_distance(TRIANGLE_B, TRIANGLE_C)
ac = cosine_distance(TRIANGLE_A, TRIANGLE_C)
print("triangle_cosine_fails", ac > ab + bc, f"{ab + bc:.6f}", f"{ac:.6f}")
ea = euclidean_distance(TRIANGLE_A, TRIANGLE_B) + euclidean_distance(TRIANGLE_B, TRIANGLE_C)
print("triangle_euclid_holds", euclidean_distance(TRIANGLE_A, TRIANGLE_C) <= ea + 1e-12)
# The search.
r1 = rank_by_cosine(QUERIES["roast it"], CATALOGUE)
r2 = rank_by_cosine(QUERIES["training for a race and what to eat"], CATALOGUE)
print("search_top1", r1[0][0], f"{r1[0][1]:.6f}")
print("search_top2", r2[0][0], f"{r2[0][1]:.6f}")
print("euclid_top1", rank_by_euclidean(QUERIES["roast it"], CATALOGUE)[0][0])
scaled = [100 * x for x in QUERIES["roast it"]]
print("scale_invariant", [k for k, _ in rank_by_cosine(scaled, CATALOGUE)] == [k for k, _ in r1])
# The zero vector is refused rather than returning NaN.
try:
cosine_similarity([0, 0, 0, 0], short)
except ValueError:
print("zero_vector", "ValueError")
else:
print("zero_vector", "NOTHING_RAISED")
# The clamp: at least one article rounds above 1.0 through the naive formula.
above = 0
for vec in CATALOGUE.values():
length = math.sqrt(sum(x * x for x in vec))
if sum(x * y for x, y in zip(vec, vec)) / (length * length) > 1.0:
above += 1
print("rounds_above_one", above >= 1, all(cosine_similarity(v, v) <= 1.0 for v in CATALOGUE.values()))
# The curse, seeded so it is reproducible.
rng = np.random.default_rng(103)
means = []
for dimension in (2, 32, 512, 8192):
a = rng.standard_normal((2000, dimension))
b = rng.standard_normal((2000, dimension))
num = np.einsum("ij,ij->i", a, b)
den = np.linalg.norm(a, axis=1) * np.linalg.norm(b, axis=1)
means.append(float(np.mean(np.abs(num / den))))
print("curse_monotone", all(y < x for x, y in zip(means, means[1:])))
print("curse_values", " ".join(f"{m:.4f}" for m in means))
PY
)"
get() { printf '%s\n' "${facts}" | grep "^$1 " | cut -d' ' -f2-; }
check_eq "the doubled copy is 9.0554 away, which is the article's own length" \
"9.0554" "$(get dist_to_doubled)"
check_eq "race-day-nutrition is only 8.0623 away" "8.0623" "$(get dist_to_rival)"
check_eq "so Euclidean puts the doubled copy FURTHER than a different article" \
"True" "$(get confound)"
check_eq "cosine calls the doubled copy identical" "1.0000000000" "$(get cos_to_doubled)"
check_eq "every from-scratch cosine agrees with NumPy inside 1e-12" \
"True" "$(get numpy_agreement)"
check_eq "the three sign cases give 18, 0 and -18" "18 0 -18" "$(get signs)"
check_eq "the 45 and 135 degree cases measure 45.00 and 135.00" \
"45.00 135.00" "$(get angles)"
# Section 6 re-runs this script with D103_SELF_TEST=1, which swaps ONE
# expectation below for a deliberately wrong one. That is how the harness
# proves it can fail rather than merely asserting that it could.
expected_normalised="True"
if [ -n "${D103_SELF_TEST:-}" ]; then
expected_normalised="False" # deliberately wrong here
fi
check_eq "on normalised vectors the two rankings are identical" \
"${expected_normalised}" "$(get normalised_orders_match)"
check_eq "on raw vectors with the doubled copy they are NOT" \
"False" "$(get raw_orders_match)"
check_eq "the unit-sphere identity sqrt(2 - 2cos) matches the measured distance" \
"True" "$(get sphere_identity)"
check_eq "cosine distance fails the triangle inequality (0.585786 < 1.000000)" \
"True 0.585786 1.000000" "$(get triangle_cosine_fails)"
check_eq "Euclidean distance holds it on the same triple" \
"True" "$(get triangle_euclid_holds)"
check_eq "the cooking note retrieves roast-chicken" \
"roast-chicken 0.993884" "$(get search_top1)"
check_eq "the training query retrieves race-day-nutrition" \
"race-day-nutrition 0.903482" "$(get search_top2)"
check_eq "raw Euclidean gets the cooking note wrong" \
"slow-cooker-stew" "$(get euclid_top1)"
check_eq "scaling the query by 100 changes nothing" "True" "$(get scale_invariant)"
check_eq "the zero vector raises rather than returning NaN" \
"ValueError" "$(get zero_vector)"
check_eq "the naive formula rounds above 1.0 and the clamp catches it" \
"True True" "$(get rounds_above_one)"
check_eq "mean absolute cosine falls at every step up in dimension" \
"True" "$(get curse_monotone)"
check_eq "and the measured values are reproducible from seed 103" \
"0.6435 0.1440 0.0353 0.0088" "$(get curse_values)"
# --------------------------------------------------------------------------
echo
echo "6. The harness can actually fail"
# --------------------------------------------------------------------------
# A green test suite proves nothing until you have watched it go red. This
# section re-runs the whole script with one expectation deliberately swapped
# for the WRONG answer, and asserts that the re-run reports the failure and
# exits non-zero. If this section passes, section 5 is not decorative.
if [ -z "${D103_SELF_TEST:-}" ]; then
self_out="$(D103_SELF_TEST=1 bash "${BASH_SOURCE[0]}" 2>&1)"
self_status=$?
if [ "${self_status}" -ne 0 ]; then
check "a deliberately wrong expectation makes the harness exit non-zero (${self_status})" "yes"
else
check "a deliberately wrong expectation makes the harness exit non-zero" "no"
fi
case "${self_out}" in
*"FAIL: on normalised vectors the two rankings are identical"*)
check "the failing check is named in the output with both values" "yes" ;;
*) check "the failing check is named in the output with both values" "no" ;;
esac
case "${self_out}" in
*", 1 failure(s)."*)
check "the summary line counts exactly one failure" "yes" ;;
*) check "the summary line counts exactly one failure" "no" ;;
esac
else
echo " (self-test run: section 6 does not recurse)"
fi
# --------------------------------------------------------------------------
echo
echo "7. Nothing was left behind"
# --------------------------------------------------------------------------
# `.venv` is pruned from the searches below. A virtual environment ships the
# installed packages' own precompiled bytecode -- hundreds of __pycache__
# directories that came with NumPy or pytest and have nothing to do with
# whether THIS lab tidied up after itself. Without the prune, following the
# README's own setup instructions makes this check fail, which reports a
# problem the reader cannot fix and did not cause.
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -print -quit 2>/dev/null | grep -q .; then
check "no __pycache__ directory anywhere under the lab after a full run" "no"
else
check "no __pycache__ directory anywhere under the lab after a full run" "yes"
fi
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -print -quit 2>/dev/null | grep -q .; then
check "no .pytest_cache directory left under the lab" "no"
else
check "no .pytest_cache directory left under the lab" "yes"
fi
if grep -rqE 'urlopen|requests\.|socket\.|http://|https://' \
"${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
check "no lab source opens a network connection" "no"
else
check "no lab source opens a network connection" "yes"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 103 lab
Every symptom below was either produced deliberately while building this lab or hit by accident during it. Nothing here is hypothetical.
ModuleNotFoundError: No module named 'numpy'
You are running the system python3 rather than the lab's environment. From
the lab directory:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)"
Then use .venv/bin/python3 and .venv/bin/pytest rather than the bare
commands. If NumPy is already installed somewhere else you would rather use,
point the harness at it:
PYTEST=/path/to/pytest bash tests/run_tests.sh
ModuleNotFoundError: No module named 'similarity' when running a script
The scripts in examples/ import similarity.py and catalogue.py from
beside themselves, so they must be run from inside examples/:
cd examples
../.venv/bin/python3 01_the_length_confound.py
cd ..
The pytest suites do not have this problem — pytest puts each test file's directory on the import path for you.
NotImplementedError: write dot
Expected, and not an error in the usual sense. It means you have not written that function yet. The starter test suite catches it and turns it into a skip:
.venv/bin/pytest starter -q
On an untouched checkout that reports 1 passed, 51 skipped. A skip means "not
attempted". A failure means "attempted and wrong".
ZeroDivisionError: float division by zero in your normalise
You handed it the zero vector, [0, 0, 0, 0]. Its length is 0 and the function
divides by that length. This is not a case to paper over with a small epsilon —
the zero vector genuinely has no direction, so "which way does it point" has no
answer. Raise ValueError with a message saying so. The test
test_1_3_normalise_refuses_the_zero_vector checks exactly this.
ValueError: math domain error from math.acos
Or, on Python 3.14, the fuller message expected a number in range from -1 up to 1, got 1.0000000000000002.
Your cosine_similarity is not clamping. Compare a vector with itself, and
floating-point rounding can put the result one unit in the last place above
1.0, which is outside acos's domain. This is not a rare edge case: three of
this lab's six four-component integer articles miss exact 1.0 through the
unguarded formula, and race-day-nutrition is the one that overshoots. The fix
is one line before you return:
return max(-1.0, min(1.0, value))
nan appearing in a ranking
Something in your catalogue is the zero vector and your cosine_similarity
returned NaN instead of raising. Two things go wrong at once, which is why
the lab refuses the input rather than propagating it:
NaNcompares false against everything, including itself, so it sorts to an unpredictable position — Python'ssortedwill not error, it will just put it somewhere;- any mean or total computed over the column becomes
NaNtoo, so the damage spreads well beyond the one bad row.
Raise at the point of the bad input.
AssertionError from test_1_7_rank_by_cosine_breaks_ties_alphabetically
Two articles — marathon-plan and storm-bulletin — score exactly 0.0 against
the query [1, 0, 0, 0]. Your sort is leaving them in an order that depends on
dictionary insertion or on the comparison's internal details. Sort with an
explicit tie-break:
sorted(pairs, key=lambda pair: (-pair[1], pair[0]))
The negation sorts the score downwards while the label still sorts upwards.
pytest starter reports passes for exercises you have not written
This is the failure mode Day 100 shipped and then fixed, and it is the worst kind: a wrong answer with a green tick on it.
Both examples/ and starter/ contain a module called similarity. pytest
imports test files by putting their directory on sys.path, so a combined run
can import whichever similarity it saw first and reuse it for the other
suite — meaning the starter tests measure the reference solution.
Each directory has a conftest.py that prevents this by putting its own
directory first on the path and dropping any similarity imported from
elsewhere. If you have deleted or edited one of them, restore it. Section 4 of
tests/run_tests.sh checks that the guard still works, by comparing the skip
count from pytest starter against the skip count from a combined pytest run
and failing if they differ.
Your dimensionality numbers differ from the captured ones
Check the seed first. examples/07_curse_of_dimensionality.py uses
numpy.random.default_rng(103), and NumPy guarantees the same stream for the
same seed within a major version, not across one. If you are on a different
NumPy, expect the digits to move.
What must not change is the shape of the result: mean absolute cosine
falling monotonically as the dimension rises, and tracking the exact formula
gamma(d/2) / (sqrt(pi) gamma((d+1)/2)) to within a few percent. The reference
suite asserts the shape, not the digits, for exactly this reason.
The harness says pytest not found
It looked in three places, in order: the PYTEST environment variable, the
lab's own .venv/bin/pytest, and your PATH. None had it. Either install into
.venv as above, or set PYTEST to a binary that exists. The harness stops
rather than skipping checks silently, which is deliberate — a suite that
quietly runs zero checks and exits 0 is worse than one that fails.
The harness says numpy is not importable from a python it found
It resolved pytest, then looked for python3 next to it, and that
interpreter has no NumPy. This usually means PYTEST points at a pytest from
one environment while NumPy lives in another. Point PYTEST at the pytest
inside the environment that has NumPy.
__pycache__ directories left behind after a run
Section 7 of the harness fails if it finds any. The harness exports
PYTHONDONTWRITEBYTECODE=1 for its own runs, but a script you ran by hand
without it will leave them. Remove them from the lab directory:
find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +
rm -rf .pytest_cache
Windows
The lab is written for macOS and Linux and was run on macOS 26.5.2 (Apple
Silicon). On Windows, the Python and the pytest are the same; the shell is not.
tests/run_tests.sh is a bash script and needs bash — Windows Subsystem for
Linux or Git Bash both provide one, and inside WSL the instructions are the
Linux instructions unchanged. The paths also differ: .venv\Scripts\python.exe
rather than .venv/bin/python3. None of this was tested here, so it is
described rather than claimed.
Security notes
Security notes — Day 103 lab
This lab is arithmetic on twenty-four small integers and some seeded random numbers. It is one of the least dangerous things in the course. The notes below are short because there is little to say, and they are here rather than absent because "there is nothing to worry about" is a claim that should still be checked.
What this lab does to your machine
- It computes and prints. No file is created, no database is opened, no process is started, no port is bound.
- It opens no network connection. Not once, in any script or test. Section
7 of
tests/run_tests.shgreps every file underexamples/andstarter/forurlopen,requests.,socket., andhttp, and fails if any of them appears. - It needs no credentials. No account, no key, no token, no paid service.
- It needs no
sudo. If any instruction in this lab appears to require elevated privileges, that instruction is wrong; stop and re-read it. - It writes nothing outside its own directory, and by the time the harness
finishes there is nothing left inside it either. Section 7 checks for stray
__pycache__and.pytest_cachedirectories and fails if it finds one.
The one thing that touches the network
pip install -r requirements/requirements.txt downloads two packages from the
Python Package Index. That is the whole network story, it happens once, and
after it you can disconnect for good.
Two habits from Day 43 still apply and are worth restating:
- Install into a virtual environment, not the system Python. The commands
in this lab create
.venv/inside the lab directory precisely so that a mistake here cannot affect anything else on your machine, and so thatrm -rf .venvis a complete undo. - Read the package name before you press return. Typo-squatting on package
indexes is real: a package named one character away from
numpyis not NumPy. The pinned file spells both names out so you are copying rather than typing.
About the data
Everything in examples/catalogue.py is invented — the six articles, the four
features and every count in the table. They resemble no real publication and
contain nothing personal. They are the same numbers Day 99 used, deliberately,
so that you can see one dataset answer two different questions.
If you replace them with data of your own, notice what an embedding table actually is: one row per document, and enough numbers in that row to distinguish that document from every other. That is a fingerprint. Two consequences worth carrying:
- Embeddings are not anonymous. A vector derived from a person's text is derived from a person's text, and similarity search over it can link documents back to their author whether or not a name was ever stored. Treat an embedding store with the same care as the documents it came from.
- Similarity search leaks by design. The whole purpose of the index is to answer "what else is like this", and that is precisely the query an attacker with access to the index would want to run. If some documents in a store are more sensitive than others, the access control has to live at the retrieval step, not only at the document store — otherwise a search that returns a snippet has published the snippet.
Neither point is speculative and neither is exercised by this lab, which uses six invented articles about roast chicken and the weather. They are here because this is the day you learned how retrieval works, and it is the right day to learn what it exposes.
About the random numbers
examples/07_curse_of_dimensionality.py uses numpy.random.default_rng(103).
That is a seeded, reproducible generator chosen so the measurement can be
checked. It is not a cryptographic random source and must never be used as
one. When you need unpredictability rather than reproducibility — tokens,
passwords, keys, nonces — use Python's secrets module, which draws from the
operating system's cryptographic source. The distinction is not academic: a
seeded generator is designed to produce the same stream every time, which is
exactly the property you want in a test and exactly the property that makes a
secret worthless.
One correctness habit that is really a safety habit
cosine_similarity in this lab raises ValueError on a zero vector rather
than returning NaN. That looks like fussiness and it is not. An empty
document, a failed extraction or a truncated file all produce a zero vector in
a real pipeline. A NaN from one of them sorts unpredictably, spreads through
every average it touches, and produces a ranking that is quietly wrong with
nothing in the logs. Failing loudly at the point of the bad input is cheaper
than discovering the bad output three systems later.