Math, Statistics, and Data › Linear Algebra I: Vectors and Matrices › Day 102
Hands-on lab — Day 102: Linear Transformations
- ← Back to the Day 102 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-102-linear-transformations/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-102-linear-transformations
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_columns_are_landings.py && cd ..
cd examples && ../.venv/bin/python3 02_building_the_transformations.py && cd ..
cd examples && ../.venv/bin/python3 03_linear_or_not.py && cd ..
cd examples && ../.venv/bin/python3 04_composition_and_order.py && cd ..
cd examples && ../.venv/bin/python3 05_determinant_inverse_rank.py && cd ..
cd examples && ../.venv/bin/python3 06_the_limit_of_linear.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_columns_are_landings.py examples/02_building_the_transformations.py examples/03_linear_or_not.py examples/04_composition_and_order.py examples/05_determinant_inverse_rank.py examples/06_the_limit_of_linear.py examples/conftest.py examples/shapes.py examples/test_reference.py examples/transforms.py expected-output/01-columns-are-landings.txt expected-output/02-building-the-transformations.txt expected-output/03-linear-or-not.txt expected-output/04-composition-and-order.txt expected-output/05-determinant-inverse-rank.txt expected-output/06-the-limit-of-linear.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/shapes.py starter/test_starter.py starter/transforms.py tests/run_tests.sh troubleshooting.md
Lab README
Day 102 lab — Where Do the Basis Vectors Land?
Lesson
- Lesson title: Linear Transformations
- Day number: 102 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-102-linear-transformations
- 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-102-linear-transformationswhen the site is running.
Purpose
A matrix is a function. Its columns are where the basis vectors land. If you
know where (1, 0) and (0, 1) go, you know where every vector goes — because
every vector is a combination of those two, and a linear transformation is
exactly one that keeps combinations intact.
This lab makes that sentence do work. You read a matrix off a described picture
and check its columns. You derive scaling, reflection, shear and rotation
rather than memorising them, each from the single question "where do the two
arrows land". You test the definition of linear on a matrix, where it holds,
and on "matrix plus a constant", where it fails — and measure the failure,
which turns out to be exactly the constant. You compose two transformations,
discover that B @ A means A first, and confirm that the product does in one
step what the sequence did in two. You meet the determinant as a measured area
rather than a formula, including a negative one and a zero one. And you watch
numpy.linalg.inv refuse to invert the zero case.
The last script is the payoff, and it is the reason the day exists: a linear transformation always fixes the origin and always sends straight lines to straight lines, so a stack of twenty of them collapses into one 2 by 2 matrix and can draw no curve at all. That is the concrete, measured reason activation functions exist.
Every answer here is small enough to check on paper. That is deliberate. A lab about transformations whose numbers you cannot verify by hand is a lab that teaches you to trust output.
Learning objectives
By the end you will be able to:
- Read a transformation matrix off the landing places of the basis vectors, and read the landing places back off a matrix — and say why a row is not one.
- Derive the scaling, reflection, shear and rotation matrices from first principles, including deriving the rotation matrix from the unit circle.
- State the two conditions that define a linear transformation, test both, and demonstrate a function that fails both — quantifying the failure.
- Explain why a neural network layer keeps the bias separate as
X @ W + b. - Compose two transformations into one matrix, and get the order right.
- Interpret the determinant geometrically: as an area factor, with a sign that reports orientation and a zero that reports collapse.
- Say when an inverse exists, and name the exception NumPy raises when it does not.
- Explain rank in plain language and read it off a 2 by 2 matrix by eye.
- State, and demonstrate, why no stack of linear layers can separate data that needs a curve.
- Compare floats with a stated tolerance and give the reason for the number.
Prerequisites
- Day 99 — vectors: components, magnitude, and what an arrow with coordinates means.
- Day 100 — matrices, and the three ways to read one. This lab lives entirely inside the third reading.
- Day 101 — matrix multiplication as composition. The order rule here is that rule, applied.
- Day 70 — floating point, which is why every comparison in this lab declares a tolerance.
- Day 43 —
python3 -m venvand installing a package withpip. - Days 071–074 — running pytest and reading its output.
- No mathematics beyond school arithmetic. Cosine and sine are defined from the unit circle where they first appear; radians are defined in the same place.
Supported operating systems
- macOS — run and captured here (macOS 26.5.2, Apple Silicon, arm64).
- Linux — the same commands apply unchanged. Not run here.
- Windows — use the Windows Subsystem for Linux and follow the Linux
instructions, or Git Bash with
.venv\Scripts\python.exein place of.venv/bin/python3. Not run here;troubleshooting.mdsays so plainly rather than implying a test that did not happen.
Hardware requirements
Anything that runs Python. The largest object in this lab is a 2 by 2 matrix. Roughly 60 MB of disk for the virtual environment, almost all of it NumPy.
Required software
python3— 3.14.0 here.numpy2.5.2 andpytest9.1.1, installed into a lab-local virtual environment fromrequirements/requirements.txt.bash— 3.2.57 here, for the test harness.
Free and open-source options
Both dependencies are free and open source and there is no paid tier of anything in this lab. NumPy is distributed under the BSD 3-Clause licence and pytest under the MIT licence. No account, no key, no signup, personally or commercially.
If you cannot install anything at all, exercise 1 — all ten transformation
functions — runs on a bare python3 with math and nothing else. What you
lose is every cross-check against NumPy and the exercise on the exception a
singular matrix raises. requirements/README.md says exactly what that costs.
Installation
From the repository root:
cd labs/sections/math-statistics-and-data/day-102-linear-transformations
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 is the only time this lab needs the network.
File structure
.
├── README.md this file
├── metadata.yml how the lab was actually run, and when
├── requirements/
│ ├── README.md why each package is here, and its licence
│ └── requirements.txt numpy==2.5.2, pytest==9.1.1
├── starter/ your work goes here
│ ├── 00_brief.md the six exercises, in order
│ ├── conftest.py makes this directory's transforms.py the one its tests import
│ ├── shapes.py the invented data — read it, do not change it
│ ├── transforms.py exercise 1 — ten functions to write
│ ├── answers.py exercises 2 to 6 — thirty-one predictions
│ └── test_starter.py your running score; unattempted work skips
├── examples/ the reference, to read after you have tried
│ ├── conftest.py the same import guard
│ ├── shapes.py the data, plus every answer worked by hand
│ ├── transforms.py the finished from-scratch module
│ ├── 01_columns_are_landings.py a matrix off a picture, and a picture off a matrix
│ ├── 02_building_the_transformations.py scaling, reflection, shear, rotation — derived
│ ├── 03_linear_or_not.py both linearity tests, passed and failed
│ ├── 04_composition_and_order.py one matrix for two steps, and the order gotcha
│ ├── 05_determinant_inverse_rank.py area, orientation, collapse, inverse, rank
│ ├── 06_the_limit_of_linear.py why activation functions are not optional
│ └── test_reference.py 80 tests over real values and real exceptions
├── tests/
│ └── run_tests.sh the bash harness: 64 checks, exits non-zero on any failure
├── expected-output/ captured from real runs on 2026-08-16
│ ├── FIELDS.md what may legitimately differ on your machine
│ ├── 01-columns-are-landings.txt
│ ├── 02-building-the-transformations.txt
│ ├── 03-linear-or-not.txt
│ ├── 04-composition-and-order.txt
│ ├── 05-determinant-inverse-rank.txt
│ ├── 06-the-limit-of-linear.txt
│ ├── reference-tests.txt
│ ├── starter-progress.txt
│ └── test-run.txt
├── troubleshooting.md
└── security.md
How to run
Read starter/00_brief.md first. Then work, checking yourself as you go:
.venv/bin/pytest starter -q
On an untouched checkout that prints 1 passed, 53 skipped. A skip means "not
attempted"; a failure means "attempted and wrong", and prints both your answer
and the real one. When it prints 54 passed, you are finished.
Afterwards, read the reference — each script prints its working and asserts every claim it makes:
cd examples
../.venv/bin/python3 01_columns_are_landings.py
../.venv/bin/python3 02_building_the_transformations.py
../.venv/bin/python3 03_linear_or_not.py
../.venv/bin/python3 04_composition_and_order.py
../.venv/bin/python3 05_determinant_inverse_rank.py
../.venv/bin/python3 06_the_limit_of_linear.py
cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
Run them from inside examples/, because they import transforms.py and
shapes.py from beside themselves.
Then the full harness:
bash tests/run_tests.sh
echo "exit=$?"
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. rm -rf .venv is a complete undo. |
.venv/bin/pip install -r requirements/requirements.txt |
Installs numpy 2.5.2 and pytest 9.1.1. The one command that uses the network. |
.venv/bin/pytest starter -q |
Your running score. Unattempted exercises skip; wrong answers fail with both values printed. |
01_columns_are_landings.py |
Builds a matrix from two described landing places, reads the landings back off the columns, sends (2, 1) through it by hand and by NumPy, and shows the identity as the do-nothing case. |
02_building_the_transformations.py |
Derives scaling, reflection, shear and rotation from where the basis vectors go, checks each against NumPy, applies all four to a lopsided flag, and shows every one of them leaving the origin exactly where it was. |
03_linear_or_not.py |
Tests both halves of the definition of linear on M @ v, where they hold, and on M @ v + b, where they fail — measuring the gap as exactly b and exactly (s - 1) * b. |
04_composition_and_order.py |
Shears then rotates the flag step by step, builds the single matrix that does both, confirms every corner agrees, and shows that the other order is a different transformation. |
05_determinant_inverse_rank.py |
Measures the transformed unit square's area for a positive, a negative and a zero determinant; shows the collapse putting the whole plane on the line y = 2x; computes ranks against NumPy; inverts what can be inverted, and shows both refusals for what cannot. |
06_the_limit_of_linear.py |
Shows the origin fixed, straight lines staying straight, twenty stacked layers collapsing to one 2 by 2 matrix, the exclusive-or arrangement no straight line separates, and a ReLU breaking linearity so that depth starts to buy something. |
.venv/bin/pytest examples -q -p no:cacheprovider |
The 80 reference tests. -p no:cacheprovider stops pytest writing a .pytest_cache directory. |
bash tests/run_tests.sh |
The 64-check harness: versions, every script, both suites, thirty-seven individual values, a deliberate self-failure, and a clean-disk check. |
Expected output
The captured files live in expected-output/. The harness ends with:
64 checks, 0 failure(s).
and exits 0. The reference suite ends with 80 passed, and an untouched
starter with 1 passed, 53 skipped.
Four blocks worth recognising before you meet them. The determinant as a measured area:
transformation measured area determinant by hand
scaling(2, 3) 6.0 6.0 6.0
shear_x(2) 1.0 1.0 1.0
reflection in x -1.0 -1.0 -1.0
collapse 0.0 0.0 0.0
The collapse, sending two different points to one place:
Two different starting points now share a landing place:
(2, 0) -> (2.0, 4.0)
(0, 1) -> (2.0, 4.0)
The refusal:
numpy.linalg.inv raises LinAlgError: Singular matrix
And the quarter turn, which is the reason every float check here states a tolerance:
(1, 0) lands at (6.123233995736766e-17, 1.0)
(0, 1) lands at (-1.0, 6.123233995736766e-17)
expected-output/FIELDS.md records exactly which parts of the captured output
may legitimately differ on your machine — timings, the platform line, and your
own progress score — and which parts may not. It also explains the two numbers
above that look like errors and are not.
Validation steps
bash tests/run_tests.sh; echo "exit=$?"prints64 checks, 0 failure(s).andexit=0..venv/bin/pytest examples -q -p no:cacheproviderprints80 passed..venv/bin/pytest starter -q -p no:cacheproviderprints54 passedonce you have finished, and never prints a failure you have not been shown.- Each of the six scripts ends with
every assertion held. find . -type d -name '__pycache__' -o -type d -name '.pytest_cache'prints nothing after a full run.
Tests
tests/run_tests.sh runs 64 checks in seven sections:
- Versions — reads the installed numpy and compares it against
requirements/requirements.txt, and confirms it is NumPy 2 or later. - The six reference scripts — each must exit 0 and print that every one of its internal assertions held.
- The reference pytest suite — must exit 0, report no failures, and have collected at least seventy-five tests, so a collection error cannot pass as success.
- The starter suite — must exit 0 on an untouched checkout with skips
rather than failures; and collecting both suites at once must not turn any
of those skips into passes, which is a real hazard here because both
directories contain modules called
transformsandshapes. - Thirty-seven individual values — the columns and the landing places, the four derived matrices, the quarter turn's inexactness, both linearity failures and their exact sizes, both composition orders, four measured areas against four determinants, the collapse and its rank, the inverse of a shear, both singular-matrix refusals with NumPy's exact class and message, and the twenty-layer collapse.
- A deliberate failure — the harness re-runs itself with one expectation
swapped for the naive belief that
cos(pi / 2)is exactly0.0, and asserts that the re-run exits non-zero and reports exactly one failure. A green suite proves nothing until you have watched it go red. - A clean disk — no
__pycache__, no.pytest_cache, and no source file that opens a network connection.
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 lab's own commands leave none of the first two behind; section 7 of the harness fails if they appear.
Troubleshooting
See troubleshooting.md. It covers the missing-numpy and wrong-directory
import errors, why cos(pi / 2) is not zero and why numpy.linalg.det returns
7.000000000000001, the singular-matrix exception, the commonest wrong
rotation (the two signs swapped, which turns clockwise), the module-name
collision between the two directories, and the argument-evaluation trap that
made an unattempted test report as a failure — all found while building this
lab rather than imagined for the document.
Security notes
See security.md. In short: this lab computes and prints. It writes no files,
opens no connection after the one-time install, needs no credentials and no
sudo, and all the data is invented. The point worth carrying away is in that
file's last section: a transformation with determinant zero destroys
information, which is sometimes exactly the property you want and sometimes
exactly the assumption that is wrong when someone claims a step is
irreversible.
Extension exercises
- Reflection in an arbitrary line. Derive the matrix that mirrors the
plane in the line at angle
thetato the horizontal, by working out where(1, 0)and(0, 1)land. Check that its determinant is-1for everytheta, and explain why it has to be. - Projection. Build the matrix that flattens every point onto the x axis. Predict its determinant and its rank before computing them. Then answer the question that matters: what did the plane lose, and can you name two points that became indistinguishable?
- A rotation that is not about the origin. Turn the flag a quarter turn
about the point
(1, 1)rather than about the origin. You cannot do it with one matrix — prove that to yourself first — so do it as translate, rotate, translate back, and note that the middle step is the only linear one. - Three dimensions. Extend
from_landings,applyandcomposeto 3 by 3 matrices with three basis vectors. Nothing about the idea changes. Then build a rotation about the z axis and check that it leaves(0, 0, 1)alone. - Eigenvectors, by hand. Some vectors come out of a transformation pointing
the same way they went in, only longer or shorter. For
shear_x(2), find every such direction — there is exactly one, and finding it by trying candidates tells you more than the formula would. Day 106 names them. - Make the determinant lie. Find a 2 by 2 matrix of small decimals whose
true determinant is 0 but for which
numpy.linalg.detreturns something other than0.0. Then decide what tolerance you would use in production code to call a matrix singular, and what could go wrong with each choice.
Navigation
- Previous day: Day 101 — Matrix Multiplication
- Next day: Day 103 — Dot Products and Similarity
- Week 15: Linear Algebra I: Vectors and Matrices
- Section: Mathematics, Statistics and Data
Expected output
01-columns-are-landings.txt
01_columns_are_landings.py
======================================================================
1. The picture says two things, and only two things
----------------------------------------------------------------------
the arrow (1, 0) has been redrawn ending at (3.0, 1.0)
the arrow (0, 1) has been redrawn ending at (-1.0, 2.0)
Write those two landing places down as COLUMNS. That is the matrix.
row 0: [3.0, -1.0]
row 1: [1.0, 2.0]
2. Reading the picture back off the matrix
----------------------------------------------------------------------
column 0 = (3.0, 1.0) <- where (1, 0) went
column 1 = (-1.0, 2.0) <- where (0, 1) went
Careful: the matrix is WRITTEN as rows. Row 0 is [3.0, -1.0] and that
is not a landing place. (3, 1) is, and it is read downwards.
3. Now every other vector, without looking at the picture again
----------------------------------------------------------------------
(2.0, 1.0) = 2 * (1, 0) + 1 * (0, 1)
A linear transformation keeps that combination intact, so it must
land at 2 * (3, 1) + 1 * (-1, 2):
2 * (3, 1) = (6, 2)
1 * (-1, 2) = (-1, 2)
(6, 2) + (-1, 2) = (5, 4)
from-scratch apply: (5.0, 4.0)
4. NumPy, doing the same thing with the @ operator
----------------------------------------------------------------------
M @ v = [5.0, 4.0]
M @ e1 = [3.0, 1.0] (column 0)
M @ e2 = [-1.0, 2.0] (column 1)
5. The matrix that leaves the basis vectors exactly where they are
----------------------------------------------------------------------
from_landings((1, 0), (0, 1)) = [[1.0, 0.0], [0.0, 1.0]]
That is the identity matrix, and it is the identity for one reason:
nothing moved, so nothing moves.
checked on (0, 0), (2, 1) and (-3.5, 7.25): each came back unchanged
01_columns_are_landings.py: every assertion held.
02-building-the-transformations.txt
02_building_the_transformations.py
======================================================================
1. Scaling by 2 across and 3 up
----------------------------------------------------------------------
scaling(2, 3)
one step right becomes two steps right; one step up becomes three up
(1, 0) lands at (2.0, 0.0) (0, 1) lands at (0.0, 3.0)
matrix: [[2.0, 0.0], [0.0, 3.0]]
(1, 1) lands at (2.0, 3.0) checked by hand: (2, 3)
2. Reflection in the x axis
----------------------------------------------------------------------
reflection_in_x_axis()
(1, 0) is ON the mirror line so it cannot move; (0, 1) mirrors to (0, -1)
(1, 0) lands at (1.0, 0.0) (0, 1) lands at (0.0, -1.0)
matrix: [[1.0, 0.0], [0.0, -1.0]]
(2, 3) lands at (2.0, -3.0) checked by hand: (2, -3)
3. Shear: push sideways in proportion to height, k = 2
----------------------------------------------------------------------
shear_x(2)
(1, 0) has height 0 so nothing pushes it; (0, 1) has height 1 so it slides 2
(1, 0) lands at (1.0, 0.0) (0, 1) lands at (2.0, 1.0)
matrix: [[1.0, 2.0], [0.0, 1.0]]
(1, 1) lands at (3.0, 1.0) checked by hand: (3, 1)
(5, 0) lands at (5.0, 0.0) the x axis never moves
4. Rotation, derived from the unit circle
----------------------------------------------------------------------
Walk anticlockwise around a circle of radius 1, starting at (1, 0),
until you have turned through theta. Where you now stand is, BY
DEFINITION, (cos theta, sin theta). That is what those two
functions are. So (1, 0) lands at (cos theta, sin theta), and
(0, 1) -- already a quarter turn ahead -- lands a quarter turn
ahead of that, at (-sin theta, cos theta).
rotation(30 degrees):
cos = 0.8660254037844387
sin = 0.49999999999999994
(1, 0) lands at (0.8660254037844387, 0.49999999999999994)
rotation(45 degrees):
cos = 0.7071067811865476
sin = 0.7071067811865475
(1, 0) lands at (0.7071067811865476, 0.7071067811865475)
rotation(90 degrees):
cos = 6.123233995736766e-17
sin = 1.0
(1, 0) lands at (6.123233995736766e-17, 1.0)
rotation(180 degrees):
cos = -1.0
sin = 1.2246467991473532e-16
(1, 0) lands at (-1.0, 1.2246467991473532e-16)
5. The quarter turn, and why an exact comparison would fail here
----------------------------------------------------------------------
(1, 0) lands at (6.123233995736766e-17, 1.0)
(0, 1) lands at (-1.0, 6.123233995736766e-17)
On paper those are (0, 1) and (-1, 0). In binary floating point
cos(pi / 2) is 6.123233995736766e-17, which is not 0.0, because
pi itself cannot be stored exactly and the cosine of the stored
value is not the cosine of pi. The error is about 1e-17.
So every check below uses a tolerance of 1e-12: five orders of
magnitude above that rounding, and four below the smallest number
this lab cares about. `== 0.0` would fail on a correct answer.
6. NumPy builds the same four matrices
----------------------------------------------------------------------
scaling(2, 3) matches NumPy within 1e-12: True
reflection matches NumPy within 1e-12: True
shear_x(2) matches NumPy within 1e-12: True
rotation(pi/2) matches NumPy within 1e-12: True
7. The flag shape under each transformation
----------------------------------------------------------------------
original [(0.0, 0.0), (2.0, 0.0), (2.0, 0.5), (0.5, 0.5), (0.5, 2.0), (0.0, 2.0)]
scaled [(0.0, 0.0), (4.0, 0.0), (4.0, 1.5), (1.0, 1.5), (1.0, 6.0), (0.0, 6.0)]
reflected [(0.0, 0.0), (2.0, 0.0), (2.0, -0.5), (0.5, -0.5), (0.5, -2.0), (0.0, -2.0)]
sheared [(0.0, 0.0), (2.0, 0.0), (3.0, 0.5), (1.5, 0.5), (4.5, 2.0), (4.0, 2.0)]
turned [(0.0, 0.0), (0.0, 2.0), (-0.5, 2.0), (-0.5, 0.5), (-2.0, 0.5), (-2.0, 0.0)]
Every one of them left the first corner at the origin. That is not
a coincidence and it is not avoidable: M @ (0, 0) is (0, 0) for
every matrix M there has ever been. A linear transformation cannot
move the origin, which is the first of the two limits this lab is
really about.
02_building_the_transformations.py: every assertion held.
03-linear-or-not.txt
03_linear_or_not.py
======================================================================
M = [[2.0, 0.0], [0.0, 3.0]] (scale by 2 across, 3 up)
b = (1.0, 1.0)
u = (1.0, 2.0) v = (3.0, -1.0) s = 5.0
1. T(v) = M @ v -- preserves addition
----------------------------------------------------------------------
u + v = (4.0, 1.0)
T(u + v) = (8.0, 3.0)
T(u) = (2.0, 6.0)
T(v) = (6.0, -3.0)
T(u) + T(v) = (8.0, 3.0)
equal within 1e-12: True
2. T(v) = M @ v -- preserves scaling
----------------------------------------------------------------------
s * u = (5.0, 10.0)
T(s * u) = (10.0, 30.0)
s * T(u) = (10.0, 30.0)
equal within 1e-12: True
3. f(v) = M @ v + b -- fails to preserve addition
----------------------------------------------------------------------
f(u + v) = (9.0, 4.0)
f(u) = (3.0, 7.0)
f(v) = (7.0, -2.0)
f(u) + f(v) = (10.0, 5.0)
equal within 1e-12: False
the gap = (1.0, 1.0)
Look at the gap. It is exactly b. Adding the offset once on the
left and twice on the right is the entire failure -- b sneaks in
once per term, and adding two terms adds it twice.
4. f(v) = M @ v + b -- fails to preserve scaling too
----------------------------------------------------------------------
f(s * u) = (11.0, 31.0)
s * f(u) = (15.0, 35.0)
equal within 1e-12: False
the gap = (4.0, 4.0)
and (s - 1) * b = (4.0, 4.0)
Same story: b is added once before the multiply and s times after.
5. The quick check: what happens to the origin?
----------------------------------------------------------------------
T((0, 0)) = (0.0, 0.0) linear: the origin is fixed
f((0, 0)) = (1.0, 1.0) not linear: the origin moved
Every linear transformation sends the origin to the origin, because
M @ (0, 0) is a sum of zero lots of each column. So if a function
moves the origin it cannot be linear, and you knew that before
testing a single pair of vectors.
6. Which is why a network layer is written X @ W + b
----------------------------------------------------------------------
The layer is deliberately NOT one operation. X @ W is the linear
part -- it has a matrix, it has columns, it has a determinant, and
everything in this lab applies to it. The + b is bolted on
afterwards precisely because it cannot be folded into the matrix:
a matrix cannot move the origin, and b exists to move the origin.
Together they are called an AFFINE transformation: linear, plus a
shift. Neither word is decoration.
7. The same four checks in NumPy
----------------------------------------------------------------------
M @ (u + v) = [8.0, 3.0]
M @ u + M @ v = [8.0, 3.0]
(M @ (u + v) + b) = [9.0, 4.0]
(M @ u + b) + (M @ v + b) = [10.0, 5.0]
03_linear_or_not.py: every assertion held.
04-composition-and-order.txt
04_composition_and_order.py
======================================================================
A = shear_x(2) first
[[1.0, 2.0], [0.0, 1.0]]
B = rotation(pi / 2) second
[[0.0, -1.0], [1.0, 0.0]] (printed to one decimal; see script 02 for the raw values)
1. The two steps, one after the other, on the flag
----------------------------------------------------------------------
start [(0.0, 0.0), (2.0, 0.0), (2.0, 0.5), (0.5, 0.5), (0.5, 2.0), (0.0, 2.0)]
after shear [(0.0, 0.0), (2.0, 0.0), (3.0, 0.5), (1.5, 0.5), (4.5, 2.0), (4.0, 2.0)]
after turn [(0.0, 0.0), (0.0, 2.0), (-0.5, 3.0), (-0.5, 1.5), (-2.0, 4.5), (-2.0, 4.0)]
2. Building the one matrix that does both, column by column
----------------------------------------------------------------------
Where does (1, 0) end up after BOTH steps?
shear sends (1, 0) to (1.0, 0.0)
then the turn sends that to (0.0, 1.0)
Where does (0, 1) end up?
shear sends (0, 1) to (2.0, 1.0)
then the turn sends that to (-1.0, 2.0)
Write the two landings as columns and you have the composite.
compose(B, A) = [(0.0, -1.0), (1.0, 2.0)]
by hand: [[0.0, -1.0], [1.0, 2.0]]
3. One transformation, same landing places
----------------------------------------------------------------------
two steps [(0.0, 0.0), (0.0, 2.0), (-0.5, 3.0), (-0.5, 1.5), (-2.0, 4.5), (-2.0, 4.0)]
one matrix [(0.0, 0.0), (0.0, 2.0), (-0.5, 3.0), (-0.5, 1.5), (-2.0, 4.5), (-2.0, 4.0)]
every corner agrees within 1e-12
This is why composition matters in practice. A stack of twenty
transformations applied to a million points is twenty million
operations; multiplying the twenty small matrices together first
and applying one is a million. The answer is identical.
4. The order that trips everyone up
----------------------------------------------------------------------
compose(B, A) shear then turn = [(0.0, -1.0), (1.0, 2.0)]
compose(A, B) turn then shear = [(2.0, -1.0), (1.0, 0.0)]
by hand, turn then shear: [[2.0, -1.0], [1.0, 0.0]]
the two products differ: True
and they send (1.0, 1.0) to different places:
shear then turn -> (-1.0, 3.0)
turn then shear -> (1.0, 1.0)
Read a product RIGHT TO LEFT. B @ A means A first. It looks
backwards until you write out B @ (A @ v): A is the one standing
next to the vector, so A is the one that touches it first.
5. A free consequence: the area factors multiply
----------------------------------------------------------------------
det(A) = 1.0 (a shear preserves area)
det(B) = 1.0 (a turn preserves area)
det(C) = 1.0
det(A) * det(B) = 1.0
Here both factors are 1, so the product is 1 and the composite
preserves area as well. The rule is general and it is obvious once
said out loud: if one step doubles area and the next triples it,
area comes out six times bigger, whatever the matrices look like.
A useful corollary: if either factor is 0, so is the product, so
once a collapse has happened nothing downstream can undo it.
6. NumPy, with the @ operator
----------------------------------------------------------------------
B @ A = [[0.0, -1.0], [1.0, 2.0]]
A @ B = [[2.0, -1.0], [1.0, 0.0]]
B @ (A @ v) equals (B @ A) @ v, which is the whole justification
for composing matrices at all.
04_composition_and_order.py: every assertion held.
05-determinant-inverse-rank.txt
05_determinant_inverse_rank.py
======================================================================
1. The unit square, before anything happens to it
----------------------------------------------------------------------
corners (anticlockwise): [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
signed area: 1.0
Anticlockwise gives a POSITIVE area. List the same four corners
the other way round and the shoelace formula returns -1. That sign
is the thing to watch.
2. Send it through four transformations and measure what comes out
----------------------------------------------------------------------
transformation measured area determinant by hand
scaling(2, 3) 6.0 6.0 6.0
shear_x(2) 1.0 1.0 1.0
reflection in x -1.0 -1.0 -1.0
collapse 0.0 0.0 0.0
The measured area and the determinant are the same number every
time, sign included. The determinant is not a formula that happens
to be useful; it is the area factor, computed without drawing.
3. What a NEGATIVE determinant means
----------------------------------------------------------------------
before: [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
after : [(0.0, 0.0), (1.0, 0.0), (1.0, -1.0), (0.0, -1.0)]
signed area went from 1.0 to -1.0
The size did not change -- the square is still area 1. What
changed is that the corners now run clockwise. The plane was
turned over, and no amount of rotating will turn it back, the same
way no amount of turning a left glove makes it a right one.
4. What a ZERO determinant means
----------------------------------------------------------------------
G = [[1.0, 2.0], [2.0, 4.0]]
column 0 = (1, 2) column 1 = (2, 4)
The second column is exactly twice the first. Both basis vectors
land on the SAME LINE through the origin, so everything else does
too -- there is nowhere else left to land.
(1.0, 0.0) -> (1.0, 2.0) on the line y = 2x: True
(0.0, 1.0) -> (2.0, 4.0) on the line y = 2x: True
(3.0, -1.0) -> (1.0, 2.0) on the line y = 2x: True
(7.0, 7.0) -> (21.0, 42.0) on the line y = 2x: True
the unit square becomes [(0.0, 0.0), (1.0, 2.0), (3.0, 6.0), (2.0, 4.0)]
its area is 0.0
Two different starting points now share a landing place:
(2, 0) -> (2.0, 4.0)
(0, 1) -> (2.0, 4.0)
No rule can send that shared place back to both of them. The
information is not hidden, it is gone, and that is exactly what
'no inverse' means.
5. Rank: how many dimensions survive
----------------------------------------------------------------------
identity rank 2 numpy.linalg.matrix_rank: 2
scaling(2, 3) rank 2 numpy.linalg.matrix_rank: 2
shear_x(2) rank 2 numpy.linalg.matrix_rank: 2
collapse rank 1 numpy.linalg.matrix_rank: 1
everything to the origin rank 0 numpy.linalg.matrix_rank: 0
Rank 2: the output still fills the plane. Rank 1: it is squashed
onto a line. Rank 0: everything lands on the origin. For a square
matrix, full rank and a non-zero determinant are the same sentence.
6. The inverse, where one exists
----------------------------------------------------------------------
shear_x(2) [[1.0, 2.0], [0.0, 1.0]]
its inverse [[1.0, -2.0], [-0.0, 1.0]]
which is shear_x(-2) -- push the deck of cards back the other way.
inverse @ original = [[1.0, 0.0], [0.0, 1.0]] the identity
(1.0, 1.0) -> (3.0, 1.0) -> (1.0, 1.0)
scaling(2, 3) inverse: [[0.5, -0.0], [-0.0, 0.3333333333333333]]
which is scaling by 1/2 and 1/3, as it must be.
7. Asking for the inverse of a collapse
----------------------------------------------------------------------
from-scratch inverse raises SingularMatrix: Singular matrix: the determinant is 0, so this transformation collapses the plane and cannot be undone
numpy.linalg.inv raises LinAlgError: Singular matrix
Both refuse, and both refusals are catchable as ValueError:
SingularMatrix is a ValueError: True
numpy.linalg.LinAlgError is one too: True
8. An honest difference between the two determinants
----------------------------------------------------------------------
P = [[3.0, -1.0], [1.0, 2.0]]
by hand: 3 * 2 - (-1) * 1 = 7.0
from-scratch: 7.0
numpy.linalg.det: 7.000000000000001
they differ by 8.881784197001252e-16
This is not a bug in either one. The from-scratch version computes
a*d - b*c directly, which for these four whole numbers is exact.
numpy.linalg.det factorises the matrix first -- the same routine
it uses for a 500 by 500 matrix, where the direct formula is not
an option -- and that factorisation rounds. The general method
pays a little accuracy on tiny inputs to stay usable on large
ones. It is a good trade and it is worth knowing about, because
it is why you compare determinants with a tolerance rather than
with ==.
05_determinant_inverse_rank.py: every assertion held.
06-the-limit-of-linear.txt
06_the_limit_of_linear.py
======================================================================
1. The origin never moves
----------------------------------------------------------------------
M @ (0, 0) = (0.0, 0.0)
M @ (0, 0) is 0 lots of column 0 plus 0 lots of column 1, which is
(0, 0) whatever the columns hold. There is no matrix anywhere that
moves the origin. If your data needs shifting, that shift has to
arrive from somewhere else -- and in a network layer it does,
under the name b.
2. Straight lines land as straight lines
----------------------------------------------------------------------
p = (1.0, 3.0) q = (4.0, -2.0) midpoint of p and q = (2.5, 0.5)
M @ midpoint = (2.564582562299, 2.058012701892)
midpoint of M@p and M@q = (2.564582562299, 2.058012701892)
the same within 1e-12: True
And not just the midpoint. Eleven points spaced evenly along the
line from p to q stay evenly spaced along a line after the transform:
worst disagreement across all eleven points: 8.881784197001252e-16
Evenly spaced in, evenly spaced out. A transformation that cannot
bend a line cannot draw a curve, and a boundary that needs a curve
is therefore out of reach -- not hard, out of reach.
3. A stack of linear layers is one linear layer
----------------------------------------------------------------------
Twenty 2 by 2 matrices with pseudo-random entries (seed 102),
applied one after another to a point.
twenty applications: (-3.5017165700891133, -4.824470758326175)
one combined matrix: (-3.501716570089111, -4.824470758326172)
the combined matrix is [[-2.5255936621574815, 4.334502516447186], [-3.4796240129856693, 5.97183487309051]]
largest relative difference: 6.341021624128209e-16
A wider tolerance is used here on purpose, and saying why is more
useful than hiding it: these entries are not small whole numbers,
the two routes multiply them in a different ORDER, and floating
point addition is not associative, so twenty layers of rounding
accumulate. On this run the two routes agreed to within a
relative 1e-15 -- close, but not to the last bit, and demanding
the last bit would be demanding something arithmetic does not
promise. The check allows 1e-9, which is loose enough to stay
true on another machine and tight enough to catch a real error.
The point stands regardless of the last few digits: twenty layers
did nothing that one 2 by 2 matrix could not do. Depth bought
nothing. Stack a thousand and it is still one matrix.
4. The data that no stack of these can separate
----------------------------------------------------------------------
Four points, the classic exclusive-or arrangement:
(0, 0) -> class A (1, 1) -> class A
(1, 0) -> class B (0, 1) -> class B
Class A sits on one diagonal and class B on the other. No straight
line separates them -- try it on paper, it takes about ten seconds
to convince yourself. A linear transformation followed by a
threshold can only ever cut the plane with a straight line, and a
stack of them still cuts with a straight line, because point 3
says the stack IS one transformation.
Notice that (0, 0) is one of the four. It cannot be moved at all
by any matrix, so it is not even a matter of finding good weights.
Checked against four different matrices: (0, 0) stayed at (0, 0)
in every one.
5. Which is what the activation function is for
----------------------------------------------------------------------
Put a function that is NOT linear between the layers -- ReLU, which
replaces every negative number with zero, is the usual one -- and
the collapse in point 3 stops working, because you can no longer
slide the matrices together past it. Depth starts to buy something.
M @ (1, -1) = (0.066987, -1.116025)
ReLU of that = (0.066987, 0.0)
relu(M @ (u + v)) = (2.098076, 2.366025)
relu(M@u) + relu(M@v) = (2.098076, 3.482051)
Not equal -- so this composite is not linear, so it is not a
matrix, so no amount of algebra folds the layers together. The
non-linearity is doing the one job the matrix cannot.
6. NumPy says the same about the stack
----------------------------------------------------------------------
the product of all twenty: [[-2.525594, 4.334503], [-3.479624, 5.971835]]
agrees with the from-scratch product to a relative 1e-9
the unit square under the whole stack: [(0.0, 0.0), (-2.5256, -3.4796), (1.8089, 2.4922), (4.3345, 5.9718)]
still a parallelogram -- four straight edges, opposite sides
parallel, one corner nailed to the origin. Twenty layers deep.
06_the_limit_of_linear.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. 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 `80 passed in 0.08s` | `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 pass/skip glyph line, such as `.sssssss...` | `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, 53 skipped`. As you complete exercises, passes replace skips. That is the file changing because you changed, not because anything broke. |
## Must NOT differ
| What | Where | Why it is fixed |
| --- | --- | --- |
| Every matrix, point, area and determinant | all six `0*-*.txt` files | They are computed from a handful of small whole numbers with no randomness beyond one fixed seed, no clock and no file system involved. A different number means different arithmetic. |
| `80 passed` | `reference-tests.txt` | The reference suite has eighty tests. A different count means tests failed to collect. |
| `64 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. |
| `ValueError: operands` — absent | everywhere | This lab raises exactly one library exception on purpose, `numpy.linalg.LinAlgError: Singular matrix`, and asserts its class and its message. |
## The two numbers that look like errors and are not
**`6.123233995736766e-17`.** This is `cos(pi / 2)`, and it appears wherever a
quarter turn is printed raw — most visibly in
`02-building-the-transformations.txt` section 5. It is not a bug and it is not
a NumPy quirk: `pi` cannot be stored exactly in binary floating point, so the
value actually passed to `cos` is not quite pi/2, and its cosine is not quite
zero. Everything in this lab that compares a rotated coordinate uses a stated
tolerance of `1e-12` for exactly this reason, and one reference test asserts
the *inexactness itself*, so that if some future library ever made it exact the
suite would say so rather than keeping a comment that had quietly stopped being
true.
The same applies to `sin(30 degrees)`, which is `0.49999999999999994` rather
than `0.5`.
**`7.000000000000001`.** This is `numpy.linalg.det` on the matrix
`[[3, -1], [1, 2]]`, whose determinant is exactly 7 and which the lab's own
`determinant` function returns as exactly `7.0`. The difference is real and it
is explained in section 8 of `05-determinant-inverse-rank.txt`: the from-scratch
version computes `a*d - b*c` directly, which for four whole numbers is exact,
while `numpy.linalg.det` factorises the matrix first — the general routine that
also works at 500 by 500, where the direct formula is not an option — and that
factorisation rounds.
Neither is wrong. NumPy trades a last-bit error on a tiny input for a method
that stays usable on a large one, which is a good trade and worth knowing
about. It is also why you compare determinants with a tolerance rather than
with `==`. This difference was observed on this machine with numpy 2.5.2; no
claim is made about other versions or other processors, because none were run.
## The one place a wider tolerance is used, and why
`06-the-limit-of-linear.txt` compares twenty transformations applied one at a
time against the same twenty multiplied together first, and allows a relative
difference of `1e-9` rather than `1e-12`. The reason is stated in the script
itself: the entries are not small whole numbers, the two routes multiply them
in a different order, floating-point addition is not associative, and twenty
layers of rounding accumulate. Demanding the last bit there would be demanding
something arithmetic does not promise. The conclusion — that the stack collapses
to one matrix — does not depend on the last few digits.
## Reproducing these files
From the lab directory, after the one-time install:
```bash
cd examples && ../.venv/bin/python3 01_columns_are_landings.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
`transforms.py` and `shapes.py` from beside themselves.
reference-tests.txt
........................................................................ [ 90%]
........ [100%]
80 passed in 0.08s
starter-progress.txt
.sssssssssssssssssssssssssssssssssssssssssssssssssssss [100%]
1 passed, 53 skipped in 0.06s
test-run.txt
Day 102 — Where Do the Basis Vectors Land?
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
2. Every reference script runs and every assertion inside it holds
ok: 01_columns_are_landings.py exits 0
ok: 01_columns_are_landings.py reports every assertion held
ok: 02_building_the_transformations.py exits 0
ok: 02_building_the_transformations.py reports every assertion held
ok: 03_linear_or_not.py exits 0
ok: 03_linear_or_not.py reports every assertion held
ok: 04_composition_and_order.py exits 0
ok: 04_composition_and_order.py reports every assertion held
ok: 05_determinant_inverse_rank.py exits 0
ok: 05_determinant_inverse_rank.py reports every assertion held
ok: 06_the_limit_of_linear.py exits 0
ok: 06_the_limit_of_linear.py reports every assertion held
3. The reference pytest suite: real values, real exceptions
........................................................................ [ 90%]
........ [100%]
80 passed in 0.08s
ok: pytest examples exits 0
ok: no test in the reference suite failed
ok: the reference suite ran at least 75 tests (ran 80)
4. The starter suite skips unattempted work instead of failing it
.sssssssssssssssssssssssssssssssssssssssssssssssssssss [100%]
1 passed, 53 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 columns are the two landing places
ok: row 0 is NOT a landing place
ok: the matrix sends (2, 1) to the hand-worked (5, 4)
ok: the from-scratch determinant of the picture matrix is exactly 7
ok: numpy.linalg.det is NOT exactly 7 on the same matrix
ok: numpy.linalg.det is within 1e-14 of 7
ok: scaling(2, 3) is derived correctly
ok: reflection in the x axis is derived correctly
ok: shear_x(2) is derived correctly
ok: a shear leaves a point at height 0 exactly where it was
ok: cos(pi / 2) is not exactly 0.0, which is why a tolerance is required
ok: the quarter turn still lands on (0, 1) within the stated tolerance
ok: sin(30 degrees) is not exactly 0.5 either
ok: a matrix is linear on the tested pair
ok: matrix-plus-a-constant is not linear
ok: the addition failure is exactly b
ok: the scaling failure is exactly (s - 1) times b
ok: a linear map fixes the origin
ok: an affine map moves it
ok: compose(B, A) matches the hand-worked shear-then-rotate
ok: compose(A, B) matches the hand-worked rotate-then-shear
ok: the two orders are different transformations
ok: one composed matrix reproduces the two-step sequence on every corner
ok: scaling(2, 3) multiplies the unit square's area by 6
ok: a shear preserves area exactly
ok: a reflection gives a NEGATIVE area of the same size
ok: a collapse gives area 0
ok: the collapse puts every vector on the line y = 2x
ok: two different points land on the same place, so nothing can undo it
ok: the collapse has rank 1, and numpy agrees
ok: the all-zero matrix has rank 0
ok: the inverse of shear_x(2) is shear_x(-2)
ok: the from-scratch inverse refuses a singular matrix
ok: numpy.linalg.inv raises LinAlgError with the message 'Singular matrix'
ok: numpy.linalg.LinAlgError is catchable as a ValueError
ok: twenty stacked linear layers collapse to one 2 by 2 matrix
ok: and the stack still cannot move the origin
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
64 checks, 0 failure(s).
Source files
examples/01_columns_are_landings.py (3799 bytes)
"""Where do the basis vectors land? Read a matrix off a picture, and back.
Run from inside examples/:
../.venv/bin/python3 01_columns_are_landings.py
The claim under test: if you know where (1, 0) and (0, 1) land, you know where
every vector lands, and you did not need the picture for anything else.
"""
import numpy as np
import shapes
from transforms import apply, columns_of, from_landings
TOL = shapes.TOL
def main() -> None:
print("01_columns_are_landings.py")
print("=" * 70)
# -- 1. The picture, in words ---------------------------------------------
print()
print("1. The picture says two things, and only two things")
print("-" * 70)
print(f" the arrow (1, 0) has been redrawn ending at {shapes.PICTURE_E1_LANDS_AT}")
print(f" the arrow (0, 1) has been redrawn ending at {shapes.PICTURE_E2_LANDS_AT}")
print()
print(" Write those two landing places down as COLUMNS. That is the matrix.")
M = from_landings(shapes.PICTURE_E1_LANDS_AT, shapes.PICTURE_E2_LANDS_AT)
print(f" row 0: {M[0]}")
print(f" row 1: {M[1]}")
assert M == shapes.PICTURE_MATRIX
# -- 2. And back again ----------------------------------------------------
print()
print("2. Reading the picture back off the matrix")
print("-" * 70)
e1_lands, e2_lands = columns_of(M)
print(f" column 0 = {e1_lands} <- where (1, 0) went")
print(f" column 1 = {e2_lands} <- where (0, 1) went")
print()
print(" Careful: the matrix is WRITTEN as rows. Row 0 is", M[0], "and that")
print(" is not a landing place. (3, 1) is, and it is read downwards.")
assert e1_lands == shapes.PICTURE_E1_LANDS_AT
assert e2_lands == shapes.PICTURE_E2_LANDS_AT
# -- 3. Everything else follows -------------------------------------------
print()
print("3. Now every other vector, without looking at the picture again")
print("-" * 70)
v = (2.0, 1.0)
print(f" {v} = 2 * (1, 0) + 1 * (0, 1)")
print(" A linear transformation keeps that combination intact, so it must")
print(" land at 2 * (3, 1) + 1 * (-1, 2):")
print(" 2 * (3, 1) = (6, 2)")
print(" 1 * (-1, 2) = (-1, 2)")
print(" (6, 2) + (-1, 2) = (5, 4)")
landed = apply(M, v)
print(f" from-scratch apply: {landed}")
assert landed == shapes.PICTURE_SENDS_2_1_TO
# -- 4. NumPy agrees ------------------------------------------------------
print()
print("4. NumPy, doing the same thing with the @ operator")
print("-" * 70)
npM = np.array(shapes.PICTURE_MATRIX)
npv = np.array(v)
print(f" M @ v = {(npM @ npv).tolist()}")
print(f" M @ e1 = {(npM @ np.array(shapes.E1)).tolist()} (column 0)")
print(f" M @ e2 = {(npM @ np.array(shapes.E2)).tolist()} (column 1)")
assert np.allclose(npM @ npv, np.array(landed), atol=TOL)
assert np.allclose(npM @ np.array(shapes.E1), np.array(e1_lands), atol=TOL)
assert np.allclose(npM @ np.array(shapes.E2), np.array(e2_lands), atol=TOL)
# -- 5. The identity, which is the do-nothing case ------------------------
print()
print("5. The matrix that leaves the basis vectors exactly where they are")
print("-" * 70)
I = from_landings(shapes.E1, shapes.E2)
print(f" from_landings((1, 0), (0, 1)) = {I}")
print(" That is the identity matrix, and it is the identity for one reason:")
print(" nothing moved, so nothing moves.")
for point in [(0.0, 0.0), (2.0, 1.0), (-3.5, 7.25)]:
assert apply(I, point) == point
print(" checked on (0, 0), (2, 1) and (-3.5, 7.25): each came back unchanged")
assert np.allclose(np.array(I), np.eye(2), atol=TOL)
print()
print("01_columns_are_landings.py: every assertion held.")
if __name__ == "__main__":
main()
examples/02_building_the_transformations.py (6536 bytes)
"""Scaling, reflection, shear and rotation -- each matrix derived, not given.
Run from inside examples/:
../.venv/bin/python3 02_building_the_transformations.py
Nothing here is memorised. Every matrix is built by asking one question --
where does (1, 0) go, and where does (0, 1) go -- and writing the two answers
down as columns.
"""
import math
import numpy as np
import shapes
from transforms import (
apply,
reflection_in_x_axis,
rotation,
scaling,
shear_x,
transform_polygon,
)
TOL = shapes.TOL
def show(name: str, matrix, derivation: str) -> None:
e1 = (matrix[0][0], matrix[1][0])
e2 = (matrix[0][1], matrix[1][1])
print(f" {name}")
print(f" {derivation}")
print(f" (1, 0) lands at {e1} (0, 1) lands at {e2}")
print(f" matrix: [{matrix[0]}, {matrix[1]}]")
def main() -> None:
print("02_building_the_transformations.py")
print("=" * 70)
# -- Scaling ---------------------------------------------------------------
print()
print("1. Scaling by 2 across and 3 up")
print("-" * 70)
S = scaling(shapes.SCALE_X, shapes.SCALE_Y)
show(
"scaling(2, 3)",
S,
"one step right becomes two steps right; one step up becomes three up",
)
assert S == shapes.SCALE_MATRIX
print(f" (1, 1) lands at {apply(S, (1.0, 1.0))} checked by hand: (2, 3)")
assert apply(S, (1.0, 1.0)) == (2.0, 3.0)
# -- Reflection ------------------------------------------------------------
print()
print("2. Reflection in the x axis")
print("-" * 70)
F = reflection_in_x_axis()
show(
"reflection_in_x_axis()",
F,
"(1, 0) is ON the mirror line so it cannot move; (0, 1) mirrors to (0, -1)",
)
assert F == shapes.FLIP_MATRIX
print(f" (2, 3) lands at {apply(F, (2.0, 3.0))} checked by hand: (2, -3)")
assert apply(F, (2.0, 3.0)) == (2.0, -3.0)
# -- Shear ----------------------------------------------------------------
print()
print("3. Shear: push sideways in proportion to height, k = 2")
print("-" * 70)
H = shear_x(shapes.SHEAR_K)
show(
"shear_x(2)",
H,
"(1, 0) has height 0 so nothing pushes it; (0, 1) has height 1 so it slides 2",
)
assert H == shapes.SHEAR_MATRIX
print(f" (1, 1) lands at {apply(H, (1.0, 1.0))} checked by hand: (3, 1)")
print(f" (5, 0) lands at {apply(H, (5.0, 0.0))} the x axis never moves")
assert apply(H, (1.0, 1.0)) == (3.0, 1.0)
assert apply(H, (5.0, 0.0)) == (5.0, 0.0)
# -- Rotation --------------------------------------------------------------
print()
print("4. Rotation, derived from the unit circle")
print("-" * 70)
print(" Walk anticlockwise around a circle of radius 1, starting at (1, 0),")
print(" until you have turned through theta. Where you now stand is, BY")
print(" DEFINITION, (cos theta, sin theta). That is what those two")
print(" functions are. So (1, 0) lands at (cos theta, sin theta), and")
print(" (0, 1) -- already a quarter turn ahead -- lands a quarter turn")
print(" ahead of that, at (-sin theta, cos theta).")
print()
for degrees in (30, 45, 90, 180):
R = rotation(math.radians(degrees))
print(f" rotation({degrees} degrees):")
print(f" cos = {R[0][0]!r}")
print(f" sin = {R[1][0]!r}")
print(f" (1, 0) lands at ({R[0][0]!r}, {R[1][0]!r})")
print()
print("5. The quarter turn, and why an exact comparison would fail here")
print("-" * 70)
Q = rotation(math.pi / 2)
e1_lands = apply(Q, shapes.E1)
e2_lands = apply(Q, shapes.E2)
print(f" (1, 0) lands at {e1_lands!r}")
print(f" (0, 1) lands at {e2_lands!r}")
print()
print(" On paper those are (0, 1) and (-1, 0). In binary floating point")
print(f" cos(pi / 2) is {math.cos(math.pi / 2)!r}, which is not 0.0, because")
print(" pi itself cannot be stored exactly and the cosine of the stored")
print(" value is not the cosine of pi. The error is about 1e-17.")
print(f" So every check below uses a tolerance of {TOL!r}: five orders of")
print(" magnitude above that rounding, and four below the smallest number")
print(" this lab cares about. `== 0.0` would fail on a correct answer.")
assert e1_lands != (0.0, 1.0), "exact equality really does fail here"
assert abs(e1_lands[0] - 0.0) <= TOL and abs(e1_lands[1] - 1.0) <= TOL
assert abs(e2_lands[0] - (-1.0)) <= TOL and abs(e2_lands[1] - 0.0) <= TOL
# -- 6. NumPy agrees on all four ------------------------------------------
print()
print("6. NumPy builds the same four matrices")
print("-" * 70)
theta = math.pi / 2
npQ = np.array(
[[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]
)
for name, mine, theirs in [
("scaling(2, 3)", S, np.diag([2.0, 3.0])),
("reflection", F, np.array([[1.0, 0.0], [0.0, -1.0]])),
("shear_x(2)", H, np.array([[1.0, 2.0], [0.0, 1.0]])),
("rotation(pi/2)", Q, npQ),
]:
agree = np.allclose(np.array(mine), theirs, atol=TOL)
print(f" {name:<16} matches NumPy within {TOL!r}: {agree}")
assert agree
# -- 7. The flag, transformed ---------------------------------------------
print()
print("7. The flag shape under each transformation")
print("-" * 70)
print(f" original {[tuple(p) for p in shapes.FLAG]}")
for name, M in [("scaled", S), ("reflected", F), ("sheared", H), ("turned", Q)]:
moved = transform_polygon(M, shapes.FLAG)
rounded = [(round(x, 6) + 0.0, round(y, 6) + 0.0) for x, y in moved]
print(f" {name:<13} {rounded}")
# The corner at the origin never moves, under any of them. A linear
# transformation always fixes the origin -- there is no matrix that
# can move it, because M @ (0, 0) is (0, 0) whatever M holds.
assert abs(moved[0][0]) <= TOL and abs(moved[0][1]) <= TOL
print()
print(" Every one of them left the first corner at the origin. That is not")
print(" a coincidence and it is not avoidable: M @ (0, 0) is (0, 0) for")
print(" every matrix M there has ever been. A linear transformation cannot")
print(" move the origin, which is the first of the two limits this lab is")
print(" really about.")
print()
print("02_building_the_transformations.py: every assertion held.")
if __name__ == "__main__":
main()
examples/03_linear_or_not.py (6302 bytes)
"""What makes a transformation linear -- tested on one that is, and one that is not.
Run from inside examples/:
../.venv/bin/python3 03_linear_or_not.py
Linear means exactly two things, and nothing else:
1. it preserves addition: T(u + v) = T(u) + T(v)
2. it preserves scaling: T(s * u) = s * T(u)
Both must hold, for every u, v and s. This script checks both on a matrix,
where they hold, and on "multiply by a matrix and then add a constant", where
they do not -- and measures exactly how much they miss by, because the size of
the gap turns out to be the whole explanation.
"""
import numpy as np
import shapes
from transforms import apply, is_linear, preserves_addition, preserves_scaling
TOL = shapes.TOL
U = (1.0, 2.0)
V = (3.0, -1.0)
S = 5.0
OFFSET = (1.0, 1.0)
def main() -> None:
print("03_linear_or_not.py")
print("=" * 70)
M = shapes.SCALE_MATRIX
def linear(p):
"""v -> M @ v. Nothing else."""
return apply(M, p)
def affine(p):
"""v -> M @ v + b. One addition more, and no longer linear."""
x, y = apply(M, p)
return (x + OFFSET[0], y + OFFSET[1])
print()
print(f" M = [{M[0]}, {M[1]}] (scale by 2 across, 3 up)")
print(f" b = {OFFSET}")
print(f" u = {U} v = {V} s = {S}")
# -- 1. The matrix passes both halves -------------------------------------
print()
print("1. T(v) = M @ v -- preserves addition")
print("-" * 70)
ok, together, separately = preserves_addition(linear, U, V, TOL)
print(f" u + v = {(U[0] + V[0], U[1] + V[1])}")
print(f" T(u + v) = {together}")
print(f" T(u) = {linear(U)}")
print(f" T(v) = {linear(V)}")
print(f" T(u) + T(v) = {separately}")
print(f" equal within {TOL!r}: {ok}")
assert ok
print()
print("2. T(v) = M @ v -- preserves scaling")
print("-" * 70)
ok, scaled_first, scaled_after = preserves_scaling(linear, U, S, TOL)
print(f" s * u = {(S * U[0], S * U[1])}")
print(f" T(s * u) = {scaled_first}")
print(f" s * T(u) = {scaled_after}")
print(f" equal within {TOL!r}: {ok}")
assert ok
assert is_linear(linear, U, V, S, TOL)
# -- 3. Adding a constant breaks it ---------------------------------------
print()
print("3. f(v) = M @ v + b -- fails to preserve addition")
print("-" * 70)
ok_add, together, separately = preserves_addition(affine, U, V, TOL)
print(f" f(u + v) = {together}")
print(f" f(u) = {affine(U)}")
print(f" f(v) = {affine(V)}")
print(f" f(u) + f(v) = {separately}")
gap_add = (separately[0] - together[0], separately[1] - together[1])
print(f" equal within {TOL!r}: {ok_add}")
print(f" the gap = {gap_add}")
print()
print(" Look at the gap. It is exactly b. Adding the offset once on the")
print(" left and twice on the right is the entire failure -- b sneaks in")
print(" once per term, and adding two terms adds it twice.")
assert not ok_add
assert abs(gap_add[0] - OFFSET[0]) <= TOL
assert abs(gap_add[1] - OFFSET[1]) <= TOL
print()
print("4. f(v) = M @ v + b -- fails to preserve scaling too")
print("-" * 70)
ok_scale, scaled_first, scaled_after = preserves_scaling(affine, U, S, TOL)
print(f" f(s * u) = {scaled_first}")
print(f" s * f(u) = {scaled_after}")
gap_scale = (scaled_after[0] - scaled_first[0], scaled_after[1] - scaled_first[1])
print(f" equal within {TOL!r}: {ok_scale}")
print(f" the gap = {gap_scale}")
print(f" and (s - 1) * b = {((S - 1) * OFFSET[0], (S - 1) * OFFSET[1])}")
print()
print(" Same story: b is added once before the multiply and s times after.")
assert not ok_scale
assert abs(gap_scale[0] - (S - 1) * OFFSET[0]) <= TOL
assert abs(gap_scale[1] - (S - 1) * OFFSET[1]) <= TOL
assert not is_linear(affine, U, V, S, TOL)
# -- 5. The one-second version of the same test ---------------------------
print()
print("5. The quick check: what happens to the origin?")
print("-" * 70)
print(f" T((0, 0)) = {linear((0.0, 0.0))} linear: the origin is fixed")
print(f" f((0, 0)) = {affine((0.0, 0.0))} not linear: the origin moved")
print()
print(" Every linear transformation sends the origin to the origin, because")
print(" M @ (0, 0) is a sum of zero lots of each column. So if a function")
print(" moves the origin it cannot be linear, and you knew that before")
print(" testing a single pair of vectors.")
assert linear((0.0, 0.0)) == (0.0, 0.0)
assert affine((0.0, 0.0)) == OFFSET
# -- 6. Why a network keeps the bias separate -----------------------------
print()
print("6. Which is why a network layer is written X @ W + b")
print("-" * 70)
print(" The layer is deliberately NOT one operation. X @ W is the linear")
print(" part -- it has a matrix, it has columns, it has a determinant, and")
print(" everything in this lab applies to it. The + b is bolted on")
print(" afterwards precisely because it cannot be folded into the matrix:")
print(" a matrix cannot move the origin, and b exists to move the origin.")
print(" Together they are called an AFFINE transformation: linear, plus a")
print(" shift. Neither word is decoration.")
# -- 7. NumPy, saying the same thing --------------------------------------
print()
print("7. The same four checks in NumPy")
print("-" * 70)
npM = np.array(M)
npb = np.array(OFFSET)
npu, npv = np.array(U), np.array(V)
print(f" M @ (u + v) = {(npM @ (npu + npv)).tolist()}")
print(f" M @ u + M @ v = {(npM @ npu + npM @ npv).tolist()}")
print(f" (M @ (u + v) + b) = {(npM @ (npu + npv) + npb).tolist()}")
print(f" (M @ u + b) + (M @ v + b) = {(npM @ npu + npb + npM @ npv + npb).tolist()}")
assert np.allclose(npM @ (npu + npv), npM @ npu + npM @ npv, atol=TOL)
assert not np.allclose(
npM @ (npu + npv) + npb, npM @ npu + npb + npM @ npv + npb, atol=TOL
)
print()
print("03_linear_or_not.py: every assertion held.")
if __name__ == "__main__":
main()
examples/04_composition_and_order.py (6877 bytes)
"""Doing two transformations is one matrix -- and the order is not what it reads like.
Run from inside examples/:
../.venv/bin/python3 04_composition_and_order.py
Two claims, both checked against real numbers:
1. shearing and then rotating a shape gives exactly the same answer as
transforming it once by the product of the two matrices;
2. that product is written ROTATE @ SHEAR, with the FIRST step on the
RIGHT -- and writing it the other way round gives a different
transformation, not a differently-spelled one.
"""
import math
import numpy as np
import shapes
from transforms import (
apply,
compose,
determinant,
rotation,
shear_x,
transform_polygon,
)
TOL = shapes.TOL
def rounded(points):
return [(round(x, 6) + 0.0, round(y, 6) + 0.0) for x, y in points]
def main() -> None:
print("04_composition_and_order.py")
print("=" * 70)
A = shear_x(shapes.SHEAR_K) # step one
B = rotation(math.pi / 2) # step two
print()
print(" A = shear_x(2) first")
print(f" [{A[0]}, {A[1]}]")
print(" B = rotation(pi / 2) second")
print(f" [[{B[0][0]:.1f}, {B[0][1]:.1f}], [{B[1][0]:.1f}, {B[1][1]:.1f}]]"
" (printed to one decimal; see script 02 for the raw values)")
# -- 1. One step at a time -------------------------------------------------
print()
print("1. The two steps, one after the other, on the flag")
print("-" * 70)
step1 = transform_polygon(A, shapes.FLAG)
step2 = transform_polygon(B, step1)
print(f" start {rounded(shapes.FLAG)}")
print(f" after shear {rounded(step1)}")
print(f" after turn {rounded(step2)}")
# -- 2. The single matrix that does both ----------------------------------
print()
print("2. Building the one matrix that does both, column by column")
print("-" * 70)
print(" Where does (1, 0) end up after BOTH steps?")
after_e1 = apply(B, apply(A, shapes.E1))
print(f" shear sends (1, 0) to {apply(A, shapes.E1)}")
print(f" then the turn sends that to {rounded([after_e1])[0]}")
print(" Where does (0, 1) end up?")
after_e2 = apply(B, apply(A, shapes.E2))
print(f" shear sends (0, 1) to {apply(A, shapes.E2)}")
print(f" then the turn sends that to {rounded([after_e2])[0]}")
print(" Write the two landings as columns and you have the composite.")
C = compose(B, A)
print(f" compose(B, A) = [{rounded([tuple(C[0])])[0]}, {rounded([tuple(C[1])])[0]}]")
print(f" by hand: [{shapes.SHEAR_THEN_ROTATE[0]}, {shapes.SHEAR_THEN_ROTATE[1]}]")
for row_mine, row_hand in zip(C, shapes.SHEAR_THEN_ROTATE):
for got, want in zip(row_mine, row_hand):
assert abs(got - want) <= TOL, (got, want)
# -- 3. One step gives the same answer as two -----------------------------
print()
print("3. One transformation, same landing places")
print("-" * 70)
at_once = transform_polygon(C, shapes.FLAG)
print(f" two steps {rounded(step2)}")
print(f" one matrix {rounded(at_once)}")
for (x1, y1), (x2, y2) in zip(step2, at_once):
assert abs(x1 - x2) <= TOL and abs(y1 - y2) <= TOL
print(f" every corner agrees within {TOL!r}")
print()
print(" This is why composition matters in practice. A stack of twenty")
print(" transformations applied to a million points is twenty million")
print(" operations; multiplying the twenty small matrices together first")
print(" and applying one is a million. The answer is identical.")
# -- 4. The order gotcha ---------------------------------------------------
print()
print("4. The order that trips everyone up")
print("-" * 70)
other = compose(A, B) # rotate FIRST, then shear
print(f" compose(B, A) shear then turn = "
f"[{rounded([tuple(C[0])])[0]}, {rounded([tuple(C[1])])[0]}]")
print(f" compose(A, B) turn then shear = "
f"[{rounded([tuple(other[0])])[0]}, {rounded([tuple(other[1])])[0]}]")
print(f" by hand, turn then shear: "
f"[{shapes.ROTATE_THEN_SHEAR[0]}, {shapes.ROTATE_THEN_SHEAR[1]}]")
for row_mine, row_hand in zip(other, shapes.ROTATE_THEN_SHEAR):
for got, want in zip(row_mine, row_hand):
assert abs(got - want) <= TOL, (got, want)
differs = any(
abs(a - b) > TOL for r1, r2 in zip(C, other) for a, b in zip(r1, r2)
)
print(f" the two products differ: {differs}")
assert differs
probe = (1.0, 1.0)
print(f" and they send {probe} to different places:")
print(f" shear then turn -> {rounded([apply(C, probe)])[0]}")
print(f" turn then shear -> {rounded([apply(other, probe)])[0]}")
assert rounded([apply(C, probe)])[0] != rounded([apply(other, probe)])[0]
print()
print(" Read a product RIGHT TO LEFT. B @ A means A first. It looks")
print(" backwards until you write out B @ (A @ v): A is the one standing")
print(" next to the vector, so A is the one that touches it first.")
# -- 5. Determinants multiply ---------------------------------------------
print()
print("5. A free consequence: the area factors multiply")
print("-" * 70)
dA, dB, dC = determinant(A), determinant(B), determinant(C)
print(f" det(A) = {dA} (a shear preserves area)")
print(f" det(B) = {dB} (a turn preserves area)")
print(f" det(C) = {dC}")
print(f" det(A) * det(B) = {dA * dB}")
assert abs(dC - dA * dB) <= TOL
print(" Here both factors are 1, so the product is 1 and the composite")
print(" preserves area as well. The rule is general and it is obvious once")
print(" said out loud: if one step doubles area and the next triples it,")
print(" area comes out six times bigger, whatever the matrices look like.")
print(" A useful corollary: if either factor is 0, so is the product, so")
print(" once a collapse has happened nothing downstream can undo it.")
# -- 6. NumPy ---------------------------------------------------------------
print()
print("6. NumPy, with the @ operator")
print("-" * 70)
npA = np.array(A)
npB = np.array(B)
print(f" B @ A = {np.round(npB @ npA, 6).tolist()}")
print(f" A @ B = {np.round(npA @ npB, 6).tolist()}")
assert np.allclose(npB @ npA, np.array(C), atol=TOL)
assert np.allclose(npA @ npB, np.array(other), atol=TOL)
assert not np.allclose(npB @ npA, npA @ npB, atol=TOL)
v = np.array([1.0, 1.0])
assert np.allclose(npB @ (npA @ v), (npB @ npA) @ v, atol=TOL)
print(" B @ (A @ v) equals (B @ A) @ v, which is the whole justification")
print(" for composing matrices at all.")
print()
print("04_composition_and_order.py: every assertion held.")
if __name__ == "__main__":
main()
examples/05_determinant_inverse_rank.py (9349 bytes)
"""The determinant as an area factor, the inverse, and what a collapse costs.
Run from inside examples/:
../.venv/bin/python3 05_determinant_inverse_rank.py
The determinant is introduced here the way it is actually useful: send the unit
square through the transformation and measure the area of what comes out. That
number, with its sign, IS the determinant. Everything else -- when an inverse
exists, what rank means, why a collapse is permanent -- reads off it.
"""
import numpy as np
import shapes
from transforms import (
apply,
compose,
determinant,
identity,
inverse,
rank,
reflection_in_x_axis,
scaling,
shear_x,
signed_area,
transform_polygon,
SingularMatrix,
)
TOL = shapes.TOL
def main() -> None:
print("05_determinant_inverse_rank.py")
print("=" * 70)
print()
print("1. The unit square, before anything happens to it")
print("-" * 70)
print(f" corners (anticlockwise): {shapes.UNIT_SQUARE}")
print(f" signed area: {signed_area(shapes.UNIT_SQUARE)}")
assert abs(signed_area(shapes.UNIT_SQUARE) - 1.0) <= TOL
print(" Anticlockwise gives a POSITIVE area. List the same four corners")
print(" the other way round and the shoelace formula returns -1. That sign")
print(" is the thing to watch.")
# -- 2. Measure, then compare against the determinant ---------------------
print()
print("2. Send it through four transformations and measure what comes out")
print("-" * 70)
cases = [
("scaling(2, 3)", scaling(2.0, 3.0), 6.0),
("shear_x(2)", shear_x(2.0), 1.0),
("reflection in x", reflection_in_x_axis(), -1.0),
("collapse", shapes.COLLAPSE_MATRIX, 0.0),
]
print(f" {'transformation':<18}{'measured area':>15}{'determinant':>14}"
f"{'by hand':>10}")
for name, M, by_hand in cases:
moved = transform_polygon(M, shapes.UNIT_SQUARE)
area = signed_area(moved)
det = determinant(M)
print(f" {name:<18}{area:>15}{det:>14}{by_hand:>10}")
assert abs(area - det) <= TOL, (name, area, det)
assert abs(det - by_hand) <= TOL, (name, det, by_hand)
print()
print(" The measured area and the determinant are the same number every")
print(" time, sign included. The determinant is not a formula that happens")
print(" to be useful; it is the area factor, computed without drawing.")
# -- 3. What the sign means -----------------------------------------------
print()
print("3. What a NEGATIVE determinant means")
print("-" * 70)
F = reflection_in_x_axis()
flipped = transform_polygon(F, shapes.UNIT_SQUARE)
print(f" before: {shapes.UNIT_SQUARE}")
print(f" after : {flipped}")
print(f" signed area went from {signed_area(shapes.UNIT_SQUARE)} to "
f"{signed_area(flipped)}")
print(" The size did not change -- the square is still area 1. What")
print(" changed is that the corners now run clockwise. The plane was")
print(" turned over, and no amount of rotating will turn it back, the same")
print(" way no amount of turning a left glove makes it a right one.")
assert signed_area(flipped) < 0
assert abs(abs(signed_area(flipped)) - 1.0) <= TOL
# -- 4. What zero means ----------------------------------------------------
print()
print("4. What a ZERO determinant means")
print("-" * 70)
G = shapes.COLLAPSE_MATRIX
print(f" G = [{G[0]}, {G[1]}]")
print(" column 0 = (1, 2) column 1 = (2, 4)")
print(" The second column is exactly twice the first. Both basis vectors")
print(" land on the SAME LINE through the origin, so everything else does")
print(" too -- there is nowhere else left to land.")
landed = [apply(G, p) for p in [(1.0, 0.0), (0.0, 1.0), (3.0, -1.0), (7.0, 7.0)]]
for start, end in zip([(1.0, 0.0), (0.0, 1.0), (3.0, -1.0), (7.0, 7.0)], landed):
on_line = abs(end[1] - 2.0 * end[0]) <= TOL
print(f" {str(start):<12} -> {str(end):<14} on the line y = 2x: {on_line}")
assert on_line
squashed = transform_polygon(G, shapes.UNIT_SQUARE)
print(f" the unit square becomes {squashed}")
print(f" its area is {signed_area(squashed)}")
assert abs(signed_area(squashed)) <= TOL
print()
print(" Two different starting points now share a landing place:")
print(f" (2, 0) -> {apply(G, (2.0, 0.0))}")
print(f" (0, 1) -> {apply(G, (0.0, 1.0))}")
assert apply(G, (2.0, 0.0)) == apply(G, (0.0, 1.0))
print(" No rule can send that shared place back to both of them. The")
print(" information is not hidden, it is gone, and that is exactly what")
print(" 'no inverse' means.")
# -- 5. Rank ---------------------------------------------------------------
print()
print("5. Rank: how many dimensions survive")
print("-" * 70)
for name, M in [
("identity", identity()),
("scaling(2, 3)", scaling(2.0, 3.0)),
("shear_x(2)", shear_x(2.0)),
("collapse", G),
("everything to the origin", [[0.0, 0.0], [0.0, 0.0]]),
]:
mine = rank(M)
theirs = int(np.linalg.matrix_rank(np.array(M)))
print(f" {name:<26} rank {mine} numpy.linalg.matrix_rank: {theirs}")
assert mine == theirs
print()
print(" Rank 2: the output still fills the plane. Rank 1: it is squashed")
print(" onto a line. Rank 0: everything lands on the origin. For a square")
print(" matrix, full rank and a non-zero determinant are the same sentence.")
assert rank(G) == shapes.COLLAPSE_RANK
# -- 6. The inverse --------------------------------------------------------
print()
print("6. The inverse, where one exists")
print("-" * 70)
H = shear_x(2.0)
Hinv = inverse(H)
print(f" shear_x(2) [{H[0]}, {H[1]}]")
print(f" its inverse [{Hinv[0]}, {Hinv[1]}]")
print(" which is shear_x(-2) -- push the deck of cards back the other way.")
back = compose(Hinv, H)
print(f" inverse @ original = [{back[0]}, {back[1]}] the identity")
for row, want in zip(back, identity()):
for got, expect in zip(row, want):
assert abs(got - expect) <= TOL
probe = (1.0, 1.0)
there = apply(H, probe)
home = apply(Hinv, there)
print(f" {probe} -> {there} -> {home}")
assert abs(home[0] - probe[0]) <= TOL and abs(home[1] - probe[1]) <= TOL
S = scaling(2.0, 3.0)
Sinv = inverse(S)
print(f" scaling(2, 3) inverse: [{Sinv[0]}, {Sinv[1]}]")
print(" which is scaling by 1/2 and 1/3, as it must be.")
assert abs(Sinv[0][0] - 0.5) <= TOL
assert abs(Sinv[1][1] - 1.0 / 3.0) <= TOL
# -- 7. Asking for the impossible ------------------------------------------
print()
print("7. Asking for the inverse of a collapse")
print("-" * 70)
try:
inverse(G)
except SingularMatrix as exc:
print(f" from-scratch inverse raises SingularMatrix: {exc}")
else: # pragma: no cover - the assert below turns this into a failure
raise AssertionError("the from-scratch inverse should have refused")
try:
np.linalg.inv(np.array(G))
except np.linalg.LinAlgError as exc:
print(f" numpy.linalg.inv raises {type(exc).__name__}: {exc}")
else: # pragma: no cover
raise AssertionError("numpy should have refused too")
print()
print(" Both refuse, and both refusals are catchable as ValueError:")
print(f" SingularMatrix is a ValueError: "
f"{issubclass(SingularMatrix, ValueError)}")
print(f" numpy.linalg.LinAlgError is one too: "
f"{issubclass(np.linalg.LinAlgError, ValueError)}")
assert issubclass(SingularMatrix, ValueError)
assert issubclass(np.linalg.LinAlgError, ValueError)
# -- 8. Where the two determinants disagree, and why ----------------------
print()
print("8. An honest difference between the two determinants")
print("-" * 70)
P = shapes.PICTURE_MATRIX
mine = determinant(P)
theirs = float(np.linalg.det(np.array(P)))
print(f" P = [{P[0]}, {P[1]}]")
print(f" by hand: 3 * 2 - (-1) * 1 = {shapes.PICTURE_DETERMINANT}")
print(f" from-scratch: {mine!r}")
print(f" numpy.linalg.det: {theirs!r}")
print(f" they differ by {abs(mine - theirs)!r}")
print()
print(" This is not a bug in either one. The from-scratch version computes")
print(" a*d - b*c directly, which for these four whole numbers is exact.")
print(" numpy.linalg.det factorises the matrix first -- the same routine")
print(" it uses for a 500 by 500 matrix, where the direct formula is not")
print(" an option -- and that factorisation rounds. The general method")
print(" pays a little accuracy on tiny inputs to stay usable on large")
print(" ones. It is a good trade and it is worth knowing about, because")
print(" it is why you compare determinants with a tolerance rather than")
print(" with ==.")
assert abs(mine - shapes.PICTURE_DETERMINANT) <= TOL
assert abs(theirs - shapes.PICTURE_DETERMINANT) <= 1e-9
assert mine == shapes.PICTURE_DETERMINANT
print()
print("05_determinant_inverse_rank.py: every assertion held.")
if __name__ == "__main__":
main()
examples/06_the_limit_of_linear.py (9267 bytes)
"""Why "linear" is a limitation, and why activation functions are not optional.
Run from inside examples/:
../.venv/bin/python3 06_the_limit_of_linear.py
Three facts, each demonstrated rather than asserted in prose:
1. a linear transformation always fixes the origin;
2. it always sends straight lines to straight lines -- the midpoint of two
points lands on the midpoint of their landing places, every time;
3. a stack of linear transformations, however deep, collapses to ONE
matrix, so the stack can do nothing a single layer could not.
Put together, they say something concrete about neural networks: twenty
matrix layers with nothing between them are exactly one matrix layer, and no
matrix can draw a curved boundary. That is the reason a non-linear activation
sits between the layers, and it is the best thing this lab has to offer.
"""
import math
import random
import numpy as np
import shapes
from transforms import apply, compose, rotation, scaling, shear_x, transform_polygon
TOL = shapes.TOL
def main() -> None:
print("06_the_limit_of_linear.py")
print("=" * 70)
M = compose(rotation(math.radians(30)), shear_x(1.5))
# -- 1. The origin cannot move --------------------------------------------
print()
print("1. The origin never moves")
print("-" * 70)
print(f" M @ (0, 0) = {apply(M, (0.0, 0.0))}")
print(" M @ (0, 0) is 0 lots of column 0 plus 0 lots of column 1, which is")
print(" (0, 0) whatever the columns hold. There is no matrix anywhere that")
print(" moves the origin. If your data needs shifting, that shift has to")
print(" arrive from somewhere else -- and in a network layer it does,")
print(" under the name b.")
landed = apply(M, (0.0, 0.0))
assert abs(landed[0]) <= TOL and abs(landed[1]) <= TOL
# -- 2. Straight stays straight -------------------------------------------
print()
print("2. Straight lines land as straight lines")
print("-" * 70)
p = (1.0, 3.0)
q = (4.0, -2.0)
midpoint = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2)
print(f" p = {p} q = {q} midpoint of p and q = {midpoint}")
landed_mid = apply(M, midpoint)
mid_of_landed = tuple(
(a + b) / 2 for a, b in zip(apply(M, p), apply(M, q))
)
print(f" M @ midpoint = {tuple(round(c, 12) for c in landed_mid)}")
print(f" midpoint of M@p and M@q = {tuple(round(c, 12) for c in mid_of_landed)}")
print(f" the same within {TOL!r}: "
f"{all(abs(a - b) <= TOL for a, b in zip(landed_mid, mid_of_landed))}")
assert all(abs(a - b) <= TOL for a, b in zip(landed_mid, mid_of_landed))
print()
print(" And not just the midpoint. Eleven points spaced evenly along the")
print(" line from p to q stay evenly spaced along a line after the transform:")
worst = 0.0
for i in range(11):
t = i / 10
on_line = (p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1]))
expected = tuple(
a + t * (b - a) for a, b in zip(apply(M, p), apply(M, q))
)
got = apply(M, on_line)
worst = max(worst, max(abs(a - b) for a, b in zip(got, expected)))
print(f" worst disagreement across all eleven points: {worst!r}")
assert worst <= TOL
print(" Evenly spaced in, evenly spaced out. A transformation that cannot")
print(" bend a line cannot draw a curve, and a boundary that needs a curve")
print(" is therefore out of reach -- not hard, out of reach.")
# -- 3. A stack collapses --------------------------------------------------
print()
print("3. A stack of linear layers is one linear layer")
print("-" * 70)
random.seed(102)
stack = [
[[random.uniform(-2, 2) for _ in range(2)] for _ in range(2)]
for _ in range(20)
]
print(" Twenty 2 by 2 matrices with pseudo-random entries (seed 102),")
print(" applied one after another to a point.")
point = (0.7, -0.4)
one_at_a_time = point
for layer in stack:
one_at_a_time = apply(layer, one_at_a_time)
combined = stack[0]
for layer in stack[1:]:
combined = compose(layer, combined)
all_at_once = apply(combined, point)
print(f" twenty applications: {one_at_a_time}")
print(f" one combined matrix: {all_at_once}")
print(f" the combined matrix is [{combined[0]}, {combined[1]}]")
relative = max(
abs(a - b) / max(1.0, abs(a)) for a, b in zip(one_at_a_time, all_at_once)
)
print(f" largest relative difference: {relative!r}")
print()
print(" A wider tolerance is used here on purpose, and saying why is more")
print(" useful than hiding it: these entries are not small whole numbers,")
print(" the two routes multiply them in a different ORDER, and floating")
print(" point addition is not associative, so twenty layers of rounding")
print(" accumulate. On this run the two routes agreed to within a")
print(" relative 1e-15 -- close, but not to the last bit, and demanding")
print(" the last bit would be demanding something arithmetic does not")
print(" promise. The check allows 1e-9, which is loose enough to stay")
print(" true on another machine and tight enough to catch a real error.")
assert relative <= 1e-9
print()
print(" The point stands regardless of the last few digits: twenty layers")
print(" did nothing that one 2 by 2 matrix could not do. Depth bought")
print(" nothing. Stack a thousand and it is still one matrix.")
# -- 4. The consequence, made concrete -------------------------------------
print()
print("4. The data that no stack of these can separate")
print("-" * 70)
print(" Four points, the classic exclusive-or arrangement:")
print(" (0, 0) -> class A (1, 1) -> class A")
print(" (1, 0) -> class B (0, 1) -> class B")
print()
print(" Class A sits on one diagonal and class B on the other. No straight")
print(" line separates them -- try it on paper, it takes about ten seconds")
print(" to convince yourself. A linear transformation followed by a")
print(" threshold can only ever cut the plane with a straight line, and a")
print(" stack of them still cuts with a straight line, because point 3")
print(" says the stack IS one transformation.")
print()
print(" Notice that (0, 0) is one of the four. It cannot be moved at all")
print(" by any matrix, so it is not even a matter of finding good weights.")
a_points = [(0.0, 0.0), (1.0, 1.0)]
b_points = [(1.0, 0.0), (0.0, 1.0)]
for M_try in [scaling(3.0, -2.0), shear_x(4.0), rotation(1.0), combined]:
moved_a = [apply(M_try, p) for p in a_points]
assert abs(moved_a[0][0]) <= TOL and abs(moved_a[0][1]) <= TOL
print(" Checked against four different matrices: (0, 0) stayed at (0, 0)")
print(" in every one.")
print()
print("5. Which is what the activation function is for")
print("-" * 70)
print(" Put a function that is NOT linear between the layers -- ReLU, which")
print(" replaces every negative number with zero, is the usual one -- and")
print(" the collapse in point 3 stops working, because you can no longer")
print(" slide the matrices together past it. Depth starts to buy something.")
print()
relu_out = [max(0.0, c) for c in apply(M, (1.0, -1.0))]
print(f" M @ (1, -1) = {tuple(round(c, 6) for c in apply(M, (1.0, -1.0)))}")
print(f" ReLU of that = {tuple(round(c, 6) for c in relu_out)}")
u, v = (1.0, -1.0), (0.5, 2.0)
relu = lambda pt: tuple(max(0.0, c) for c in apply(M, pt))
together = relu((u[0] + v[0], u[1] + v[1]))
separately = tuple(a + b for a, b in zip(relu(u), relu(v)))
print(f" relu(M @ (u + v)) = {tuple(round(c, 6) for c in together)}")
print(f" relu(M@u) + relu(M@v) = {tuple(round(c, 6) for c in separately)}")
print(" Not equal -- so this composite is not linear, so it is not a")
print(" matrix, so no amount of algebra folds the layers together. The")
print(" non-linearity is doing the one job the matrix cannot.")
assert any(abs(a - b) > TOL for a, b in zip(together, separately))
# -- 6. NumPy -------------------------------------------------------------
print()
print("6. NumPy says the same about the stack")
print("-" * 70)
npstack = [np.array(layer) for layer in stack]
npcombined = npstack[0]
for layer in npstack[1:]:
npcombined = layer @ npcombined
print(f" the product of all twenty: {np.round(npcombined, 6).tolist()}")
assert np.allclose(npcombined, np.array(combined), rtol=1e-9, atol=0.0)
print(" agrees with the from-scratch product to a relative 1e-9")
square = transform_polygon(combined, shapes.UNIT_SQUARE)
print(f" the unit square under the whole stack: "
f"{[(round(x, 4), round(y, 4)) for x, y in square]}")
print(" still a parallelogram -- four straight edges, opposite sides")
print(" parallel, one corner nailed to the origin. Twenty layers deep.")
print()
print("06_the_limit_of_linear.py: every assertion held.")
if __name__ == "__main__":
main()
examples/conftest.py (1070 bytes)
"""Make this directory's own transforms.py the one its tests import.
Both `examples/` and `starter/` contain modules called `transforms` and
`shapes`, 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 `transforms` was seen first and then 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.
So: put this directory first on the import path, and drop any already-imported
`transforms` or `shapes` 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 ("transforms", "shapes", "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/shapes.py (4932 bytes)
"""The invented data this lab works on, and the answers worked out by hand.
Everything here is made up. There is no dataset, no download and no file to
read: a 2 by 2 matrix has four numbers in it, and the whole point of the day is
that you can check every one of them on paper.
Points are `(x, y)` pairs of plain Python numbers. Polygons are lists of points
in counter-clockwise order, because the SIGN of a polygon's area depends on
which way round you list its corners, and that sign is what tells you whether a
transformation flipped the plane over.
"""
# -- The standard basis -------------------------------------------------------
E1 = (1.0, 0.0)
E2 = (0.0, 1.0)
# -- The unit square, listed counter-clockwise from the origin ----------------
#
# Corners: origin, one step right, the far corner, one step up. Its area is 1
# and its signed area is +1. After a transformation, the signed area of the
# image is exactly the determinant of the matrix -- sign included.
UNIT_SQUARE = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
# -- The flag ------------------------------------------------------------------
#
# A deliberately lopsided shape, so you can see at a glance whether it has been
# turned, stretched, sheared or mirrored. It is an L lying on its back: a long
# foot along the x axis and a short mast going up at the left.
FLAG = [
(0.0, 0.0),
(2.0, 0.0),
(2.0, 0.5),
(0.5, 0.5),
(0.5, 2.0),
(0.0, 2.0),
]
# -- The picture you read a matrix off of --------------------------------------
#
# Exercise 1 describes a drawing in words rather than showing it, because the
# skill being trained is going from "where did the basis vectors land" to "what
# is the matrix", and a picture would let you skip the step. The drawing shows:
#
# the arrow (1, 0) has been redrawn ending at (3, 1)
# the arrow (0, 1) has been redrawn ending at (-1, 2)
#
# Those two landing places, written as COLUMNS, are the matrix.
PICTURE_E1_LANDS_AT = (3.0, 1.0)
PICTURE_E2_LANDS_AT = (-1.0, 2.0)
PICTURE_MATRIX = [
[3.0, -1.0],
[1.0, 2.0],
]
# Worked by hand, so the test has something honest to compare against.
#
# (2, 1) = 2 * (1, 0) + 1 * (0, 1)
# so it must land at 2 * (3, 1) + 1 * (-1, 2)
# = (6, 2) + (-1, 2)
# = (5, 4)
#
# and the determinant is 3 * 2 - (-1) * 1 = 6 + 1 = 7.
PICTURE_SENDS_2_1_TO = (5.0, 4.0)
PICTURE_DETERMINANT = 7.0
# -- The four standard transformations, with their hand-worked matrices --------
SCALE_X, SCALE_Y = 2.0, 3.0
SCALE_MATRIX = [[2.0, 0.0], [0.0, 3.0]] # e1 -> (2, 0), e2 -> (0, 3)
FLIP_MATRIX = [[1.0, 0.0], [0.0, -1.0]] # reflection in the x axis
SHEAR_K = 2.0
SHEAR_MATRIX = [[1.0, 2.0], [0.0, 1.0]] # e1 stays, e2 -> (2, 1)
# Rotation by a quarter turn. Written exactly here; computed from cosine and
# sine in transforms.py, where it comes out as 6.123233995736766e-17 rather
# than 0. That difference is the reason every float check in this lab states a
# tolerance.
QUARTER_TURN_MATRIX = [[0.0, -1.0], [1.0, 0.0]]
# -- Composition ---------------------------------------------------------------
#
# Shear first, then rotate a quarter turn. In matrix form that is ROT @ SHEAR,
# with the FIRST transformation written on the RIGHT, because it is the one
# standing next to the vector.
#
# ROT @ SHEAR = [[0, -1], [1, 0]] @ [[1, 2], [0, 1]]
# = [[0*1 + -1*0, 0*2 + -1*1],
# [1*1 + 0*0, 1*2 + 0*1]]
# = [[0, -1], [1, 2]]
SHEAR_THEN_ROTATE = [[0.0, -1.0], [1.0, 2.0]]
# The other order, to show that it is a different transformation entirely.
#
# SHEAR @ ROT = [[1, 2], [0, 1]] @ [[0, -1], [1, 0]]
# = [[1*0 + 2*1, 1*-1 + 2*0],
# [0*0 + 1*1, 0*-1 + 1*0]]
# = [[2, -1], [1, 0]]
ROTATE_THEN_SHEAR = [[2.0, -1.0], [1.0, 0.0]]
# -- The collapse --------------------------------------------------------------
#
# Both columns point along the same line: (2, 4) is exactly twice (1, 2). So
# every vector in the plane lands somewhere on the line through (1, 2), the
# whole plane is squashed onto a line, and the area of anything you send
# through is zero. Nothing can undo it, because everything on that line came
# from a whole line's worth of starting points.
COLLAPSE_MATRIX = [[1.0, 2.0], [2.0, 4.0]]
COLLAPSE_DETERMINANT = 0.0 # 1 * 4 - 2 * 2
COLLAPSE_RANK = 1
# -- Tolerance -----------------------------------------------------------------
#
# Why 1e-12 and not equality: cos(pi / 2) is 6.123233995736766e-17 in binary
# floating point, not 0.0, and sin(pi / 6) is 0.49999999999999994, not 0.5.
# Both are about 1e-17 away from the exact answer. 1e-12 sits five orders of
# magnitude above that error and about four below the smallest quantity this
# lab cares about (0.5), so it accepts the rounding and would still catch a
# genuinely wrong answer.
TOL = 1e-12
examples/test_reference.py (18950 bytes)
"""The reference test suite: real values, real shapes, real exceptions.
Run from the LAB DIRECTORY:
.venv/bin/pytest examples -q -p no:cacheprovider
Every float comparison here states a tolerance and the module docstring of
shapes.py says why that number was chosen. Nothing is compared with == unless
the arithmetic that produced it is exact -- and where it is exact, the test
says == on purpose, because that is a stronger claim.
"""
import math
import numpy as np
import pytest
import shapes
from transforms import (
SingularMatrix,
apply,
columns_of,
compose,
determinant,
from_landings,
identity,
inverse,
is_linear,
preserves_addition,
preserves_scaling,
rank,
reflection_in_x_axis,
reflection_in_y_axis,
rotation,
scaling,
shear_x,
shear_y,
signed_area,
transform_polygon,
)
TOL = shapes.TOL
# -- Reading a matrix off its landings ---------------------------------------
def test_from_landings_builds_the_picture_matrix():
M = from_landings(shapes.PICTURE_E1_LANDS_AT, shapes.PICTURE_E2_LANDS_AT)
assert M == shapes.PICTURE_MATRIX
def test_columns_are_the_landings_not_the_rows():
e1, e2 = columns_of(shapes.PICTURE_MATRIX)
assert e1 == (3.0, 1.0)
assert e2 == (-1.0, 2.0)
# The trap this test exists for: row 0 is (3, -1) and is NOT a landing.
assert tuple(shapes.PICTURE_MATRIX[0]) != e1
def test_the_matrix_sends_2_1_where_hand_arithmetic_says():
assert apply(shapes.PICTURE_MATRIX, (2.0, 1.0)) == shapes.PICTURE_SENDS_2_1_TO
def test_numpy_agrees_on_the_picture_matrix():
M = np.array(shapes.PICTURE_MATRIX)
assert np.allclose(M @ np.array([2.0, 1.0]), np.array([5.0, 4.0]), atol=TOL)
assert np.allclose(M @ np.array(shapes.E1), np.array([3.0, 1.0]), atol=TOL)
assert np.allclose(M @ np.array(shapes.E2), np.array([-1.0, 2.0]), atol=TOL)
def test_applying_to_a_basis_vector_just_reads_a_column():
for M in (shapes.PICTURE_MATRIX, shapes.SCALE_MATRIX, shapes.SHEAR_MATRIX):
e1, e2 = columns_of(M)
assert apply(M, shapes.E1) == e1
assert apply(M, shapes.E2) == e2
# -- The identity -------------------------------------------------------------
def test_identity_leaves_everything_alone():
I = identity()
for point in [(0.0, 0.0), (1.0, 0.0), (-3.5, 7.25), (1e6, -1e-6)]:
assert apply(I, point) == point
def test_identity_matches_numpy_eye():
assert np.allclose(np.array(identity()), np.eye(2), atol=TOL)
def test_identity_has_determinant_one_and_full_rank():
assert determinant(identity()) == 1.0
assert rank(identity()) == 2
# -- The four standard transformations ---------------------------------------
def test_scaling_matrix_is_derived_correctly():
assert scaling(2.0, 3.0) == shapes.SCALE_MATRIX
assert apply(scaling(2.0, 3.0), (1.0, 1.0)) == (2.0, 3.0)
assert apply(scaling(2.0, 3.0), (-4.0, 0.5)) == (-8.0, 1.5)
def test_reflection_in_x_axis_fixes_the_axis_and_flips_the_rest():
F = reflection_in_x_axis()
assert F == shapes.FLIP_MATRIX
assert apply(F, (5.0, 0.0)) == (5.0, 0.0)
assert apply(F, (2.0, 3.0)) == (2.0, -3.0)
def test_reflection_in_y_axis():
F = reflection_in_y_axis()
assert apply(F, (0.0, 5.0)) == (0.0, 5.0)
assert apply(F, (2.0, 3.0)) == (-2.0, 3.0)
def test_reflecting_twice_is_the_identity():
F = reflection_in_x_axis()
assert compose(F, F) == identity()
def test_shear_leaves_the_x_axis_alone_and_slides_the_rest():
H = shear_x(2.0)
assert H == shapes.SHEAR_MATRIX
assert apply(H, (5.0, 0.0)) == (5.0, 0.0)
assert apply(H, (1.0, 1.0)) == (3.0, 1.0)
assert apply(H, (0.0, 1.0)) == (2.0, 1.0)
def test_shear_y_is_the_same_idea_the_other_way_up():
V = shear_y(3.0)
assert apply(V, (0.0, 5.0)) == (0.0, 5.0)
assert apply(V, (1.0, 0.0)) == (1.0, 3.0)
@pytest.mark.parametrize(
"degrees, expected_cos, expected_sin",
[
(0, 1.0, 0.0),
(30, math.sqrt(3) / 2, 0.5),
(45, math.sqrt(2) / 2, math.sqrt(2) / 2),
(90, 0.0, 1.0),
(180, -1.0, 0.0),
],
)
def test_rotation_columns_are_the_unit_circle_coordinates(
degrees, expected_cos, expected_sin
):
R = rotation(math.radians(degrees))
e1, e2 = columns_of(R)
assert abs(e1[0] - expected_cos) <= TOL
assert abs(e1[1] - expected_sin) <= TOL
assert abs(e2[0] + expected_sin) <= TOL
assert abs(e2[1] - expected_cos) <= TOL
def test_a_quarter_turn_sends_the_basis_where_the_picture_says():
Q = rotation(math.pi / 2)
x, y = apply(Q, shapes.E1)
assert abs(x - 0.0) <= TOL and abs(y - 1.0) <= TOL
x, y = apply(Q, shapes.E2)
assert abs(x + 1.0) <= TOL and abs(y - 0.0) <= TOL
def test_the_quarter_turn_is_NOT_exactly_zero_which_is_why_tolerance_exists():
"""The honest reason this lab never uses == on a rotation.
cos(pi / 2) is 6.123233995736766e-17 rather than 0.0, because pi cannot be
stored exactly in binary and the cosine of the stored value is not the
cosine of pi. This test asserts the inexactness itself, so that if a future
NumPy or libm ever made it exact, the suite would say so rather than
silently keeping a comment that had stopped being true.
"""
assert math.cos(math.pi / 2) != 0.0
assert 0.0 < abs(math.cos(math.pi / 2)) < 1e-15
assert apply(rotation(math.pi / 2), shapes.E1) != (0.0, 1.0)
def test_sin_of_thirty_degrees_is_also_not_exactly_a_half():
assert math.sin(math.radians(30)) != 0.5
assert abs(math.sin(math.radians(30)) - 0.5) <= TOL
def test_four_quarter_turns_return_to_the_start():
Q = rotation(math.pi / 2)
back = compose(Q, compose(Q, compose(Q, Q)))
for row, want in zip(back, identity()):
for got, expect in zip(row, want):
assert abs(got - expect) <= TOL
def test_rotation_preserves_length():
R = rotation(0.9)
for point in [(3.0, 4.0), (1.0, 0.0), (-2.0, 7.0)]:
before = math.hypot(*point)
after = math.hypot(*apply(R, point))
assert abs(before - after) <= TOL
def test_all_four_match_numpy():
theta = math.pi / 2
pairs = [
(scaling(2.0, 3.0), np.diag([2.0, 3.0])),
(reflection_in_x_axis(), np.array([[1.0, 0.0], [0.0, -1.0]])),
(shear_x(2.0), np.array([[1.0, 2.0], [0.0, 1.0]])),
(
rotation(theta),
np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]),
),
]
for mine, theirs in pairs:
assert np.allclose(np.array(mine), theirs, atol=TOL)
# -- Linearity ----------------------------------------------------------------
U = (1.0, 2.0)
V = (3.0, -1.0)
S = 5.0
B = (1.0, 1.0)
def _linear(point):
return apply(shapes.SCALE_MATRIX, point)
def _affine(point):
x, y = apply(shapes.SCALE_MATRIX, point)
return (x + B[0], y + B[1])
def test_a_matrix_preserves_addition():
ok, together, separately = preserves_addition(_linear, U, V, TOL)
assert ok
assert together == (8.0, 3.0)
assert separately == (8.0, 3.0)
def test_a_matrix_preserves_scaling():
ok, first, after = preserves_scaling(_linear, U, S, TOL)
assert ok
assert first == (10.0, 30.0)
assert after == (10.0, 30.0)
def test_a_matrix_is_linear():
assert is_linear(_linear, U, V, S, TOL)
def test_adding_a_constant_breaks_addition_by_exactly_b():
ok, together, separately = preserves_addition(_affine, U, V, TOL)
assert not ok
assert together == (9.0, 4.0)
assert separately == (10.0, 5.0)
gap = (separately[0] - together[0], separately[1] - together[1])
assert gap == B
def test_adding_a_constant_breaks_scaling_by_exactly_s_minus_one_times_b():
ok, first, after = preserves_scaling(_affine, U, S, TOL)
assert not ok
assert first == (11.0, 31.0)
assert after == (15.0, 35.0)
gap = (after[0] - first[0], after[1] - first[1])
assert gap == ((S - 1) * B[0], (S - 1) * B[1])
def test_an_affine_function_is_not_linear():
assert not is_linear(_affine, U, V, S, TOL)
def test_linear_fixes_the_origin_and_affine_does_not():
assert _linear((0.0, 0.0)) == (0.0, 0.0)
assert _affine((0.0, 0.0)) == B
def test_every_matrix_in_this_lab_fixes_the_origin():
matrices = [
identity(),
scaling(2.0, 3.0),
shear_x(2.0),
reflection_in_x_axis(),
rotation(1.234),
shapes.PICTURE_MATRIX,
shapes.COLLAPSE_MATRIX,
]
for M in matrices:
x, y = apply(M, (0.0, 0.0))
assert abs(x) <= TOL and abs(y) <= TOL
def test_squaring_a_coordinate_is_not_linear():
"""A second non-linear example, so the point is not just about b."""
def squarer(point):
return (point[0] ** 2, point[1])
assert squarer((0.0, 0.0)) == (0.0, 0.0) # fixes the origin, yet still not linear
assert not is_linear(squarer, U, V, S, TOL)
def test_a_linear_map_sends_midpoints_to_midpoints():
M = compose(rotation(math.radians(30)), shear_x(1.5))
p, q = (1.0, 3.0), (4.0, -2.0)
mid = ((p[0] + q[0]) / 2, (p[1] + q[1]) / 2)
landed_mid = apply(M, mid)
mid_of_landed = tuple((a + b) / 2 for a, b in zip(apply(M, p), apply(M, q)))
assert all(abs(a - b) <= TOL for a, b in zip(landed_mid, mid_of_landed))
# -- Composition --------------------------------------------------------------
def test_composition_matches_the_hand_worked_product():
C = compose(rotation(math.pi / 2), shear_x(2.0))
for row, want in zip(C, shapes.SHEAR_THEN_ROTATE):
for got, expect in zip(row, want):
assert abs(got - expect) <= TOL
def test_the_other_order_is_a_different_transformation():
A, Bm = shear_x(2.0), rotation(math.pi / 2)
for row, want in zip(compose(A, Bm), shapes.ROTATE_THEN_SHEAR):
for got, expect in zip(row, want):
assert abs(got - expect) <= TOL
probe = (1.0, 1.0)
one = apply(compose(Bm, A), probe)
other = apply(compose(A, Bm), probe)
assert any(abs(a - b) > TOL for a, b in zip(one, other))
def test_one_matrix_reproduces_the_two_step_sequence_on_every_corner():
A, Bm = shear_x(2.0), rotation(math.pi / 2)
two_steps = transform_polygon(Bm, transform_polygon(A, shapes.FLAG))
at_once = transform_polygon(compose(Bm, A), shapes.FLAG)
for (x1, y1), (x2, y2) in zip(two_steps, at_once):
assert abs(x1 - x2) <= TOL and abs(y1 - y2) <= TOL
def test_composition_matches_numpy_matmul_in_both_orders():
A = np.array(shear_x(2.0))
Bm = np.array(rotation(math.pi / 2))
assert np.allclose(Bm @ A, np.array(compose(rotation(math.pi / 2), shear_x(2.0))), atol=TOL)
assert np.allclose(A @ Bm, np.array(compose(shear_x(2.0), rotation(math.pi / 2))), atol=TOL)
assert not np.allclose(A @ Bm, Bm @ A, atol=TOL)
def test_composing_with_the_identity_changes_nothing():
H = shear_x(2.0)
assert compose(identity(), H) == H
assert compose(H, identity()) == H
def test_determinants_multiply_under_composition():
A, Bm = scaling(2.0, 3.0), shear_x(4.0)
assert abs(determinant(compose(Bm, A)) - determinant(A) * determinant(Bm)) <= TOL
# -- Determinant, area, orientation ------------------------------------------
def test_the_unit_square_starts_with_signed_area_one():
assert abs(signed_area(shapes.UNIT_SQUARE) - 1.0) <= TOL
def test_listing_the_corners_clockwise_flips_the_sign():
assert abs(signed_area(list(reversed(shapes.UNIT_SQUARE))) + 1.0) <= TOL
@pytest.mark.parametrize(
"matrix, expected",
[
(scaling(2.0, 3.0), 6.0),
(scaling(0.5, 0.5), 0.25),
(shear_x(2.0), 1.0),
(reflection_in_x_axis(), -1.0),
(reflection_in_y_axis(), -1.0),
(identity(), 1.0),
(shapes.PICTURE_MATRIX, 7.0),
(shapes.COLLAPSE_MATRIX, 0.0),
],
)
def test_transformed_area_equals_the_determinant(matrix, expected):
area = signed_area(transform_polygon(matrix, shapes.UNIT_SQUARE))
assert abs(area - expected) <= TOL
assert abs(determinant(matrix) - expected) <= TOL
def test_a_negative_determinant_means_the_orientation_flipped():
flipped = transform_polygon(reflection_in_x_axis(), shapes.UNIT_SQUARE)
assert signed_area(flipped) < 0
assert abs(abs(signed_area(flipped)) - 1.0) <= TOL
def test_a_rotation_never_flips_orientation():
for degrees in (0, 30, 90, 180, 270, 359):
assert determinant(rotation(math.radians(degrees))) > 0
def test_rotation_determinant_is_one_which_is_the_pythagorean_identity():
for degrees in (17, 45, 123, 300):
theta = math.radians(degrees)
assert abs(determinant(rotation(theta)) - 1.0) <= TOL
# det = cos*cos - (-sin)*sin = cos^2 + sin^2, which is 1 by Pythagoras
assert abs(math.cos(theta) ** 2 + math.sin(theta) ** 2 - 1.0) <= TOL
def test_the_from_scratch_determinant_is_exact_on_whole_numbers():
assert determinant(shapes.PICTURE_MATRIX) == 7.0
assert determinant(shapes.COLLAPSE_MATRIX) == 0.0
def test_numpy_determinant_is_close_but_not_always_equal():
"""The honest difference, asserted rather than described.
numpy.linalg.det factorises the matrix -- the general method that also
works at 500 by 500 -- and that rounds. The direct a*d - b*c does not. On
this matrix, on this machine, they differ in the last bit.
"""
mine = determinant(shapes.PICTURE_MATRIX)
theirs = float(np.linalg.det(np.array(shapes.PICTURE_MATRIX)))
assert mine == 7.0
assert theirs != 7.0
assert abs(theirs - 7.0) < 1e-14
# -- Rank and collapse --------------------------------------------------------
def test_the_collapse_puts_everything_on_one_line():
G = shapes.COLLAPSE_MATRIX
for point in [(1.0, 0.0), (0.0, 1.0), (3.0, -1.0), (7.0, 7.0), (-2.5, 0.25)]:
x, y = apply(G, point)
assert abs(y - 2.0 * x) <= TOL
def test_the_collapse_sends_two_different_points_to_the_same_place():
G = shapes.COLLAPSE_MATRIX
assert apply(G, (2.0, 0.0)) == apply(G, (0.0, 1.0))
@pytest.mark.parametrize(
"matrix, expected_rank",
[
(identity(), 2),
(scaling(2.0, 3.0), 2),
(shear_x(2.0), 2),
(shapes.PICTURE_MATRIX, 2),
(shapes.COLLAPSE_MATRIX, 1),
([[0.0, 0.0], [0.0, 0.0]], 0),
([[1.0, 0.0], [0.0, 0.0]], 1),
],
)
def test_rank_matches_numpy(matrix, expected_rank):
assert rank(matrix) == expected_rank
assert int(np.linalg.matrix_rank(np.array(matrix))) == expected_rank
def test_scaling_by_zero_in_one_direction_loses_a_dimension():
flat = scaling(3.0, 0.0)
assert determinant(flat) == 0.0
assert rank(flat) == 1
for point in [(1.0, 5.0), (-2.0, 100.0)]:
assert apply(flat, point)[1] == 0.0
# -- Inverses ------------------------------------------------------------------
def test_inverse_of_a_shear_is_the_opposite_shear():
assert inverse(shear_x(2.0)) == shear_x(-2.0)
def test_inverse_of_a_scaling_divides():
inv = inverse(scaling(2.0, 4.0))
assert inv == [[0.5, 0.0], [0.0, 0.25]]
def test_inverse_composed_with_the_original_is_the_identity():
for M in [shear_x(2.0), scaling(2.0, 3.0), shapes.PICTURE_MATRIX, rotation(0.7)]:
back = compose(inverse(M), M)
for row, want in zip(back, identity()):
for got, expect in zip(row, want):
assert abs(got - expect) <= TOL
def test_a_round_trip_returns_the_original_point():
M = shapes.PICTURE_MATRIX
for point in [(1.0, 1.0), (-3.0, 2.5), (0.0, 0.0)]:
there = apply(M, point)
home = apply(inverse(M), there)
assert abs(home[0] - point[0]) <= TOL
assert abs(home[1] - point[1]) <= TOL
def test_the_inverse_determinant_is_the_reciprocal():
M = scaling(2.0, 3.0)
assert abs(determinant(inverse(M)) - 1.0 / determinant(M)) <= TOL
def test_inverting_a_collapse_raises():
with pytest.raises(SingularMatrix):
inverse(shapes.COLLAPSE_MATRIX)
def test_the_from_scratch_refusal_is_catchable_as_a_ValueError():
with pytest.raises(ValueError):
inverse(shapes.COLLAPSE_MATRIX)
def test_numpy_raises_LinAlgError_for_the_same_matrix():
with pytest.raises(np.linalg.LinAlgError) as caught:
np.linalg.inv(np.array(shapes.COLLAPSE_MATRIX))
assert "Singular matrix" in str(caught.value)
def test_numpys_error_is_also_a_ValueError():
assert issubclass(np.linalg.LinAlgError, ValueError)
with pytest.raises(ValueError):
np.linalg.inv(np.array(shapes.COLLAPSE_MATRIX))
def test_numpy_inverse_agrees_with_the_from_scratch_one():
for M in [shear_x(2.0), scaling(2.0, 3.0), shapes.PICTURE_MATRIX]:
assert np.allclose(np.linalg.inv(np.array(M)), np.array(inverse(M)), atol=TOL)
# -- The limit of linear -------------------------------------------------------
def test_a_stack_of_twenty_layers_is_one_matrix():
import random
random.seed(102)
stack = [
[[random.uniform(-2, 2) for _ in range(2)] for _ in range(2)]
for _ in range(20)
]
point = (0.7, -0.4)
stepwise = point
for layer in stack:
stepwise = apply(layer, stepwise)
combined = stack[0]
for layer in stack[1:]:
combined = compose(layer, combined)
at_once = apply(combined, point)
# A relative tolerance, because twenty layers of rounding on entries that
# are not small whole numbers is a real accumulation and pretending
# otherwise would be dishonest.
for a, b in zip(stepwise, at_once):
assert abs(a - b) <= 1e-9 * max(1.0, abs(a))
def test_a_stack_still_fixes_the_origin_and_still_makes_a_parallelogram():
M = compose(compose(rotation(0.4), shear_x(3.0)), scaling(2.0, -1.0))
corners = transform_polygon(M, shapes.UNIT_SQUARE)
assert abs(corners[0][0]) <= TOL and abs(corners[0][1]) <= TOL
# Opposite sides of the image are still parallel and equal, which is what
# "still a parallelogram" means numerically.
side_a = (corners[1][0] - corners[0][0], corners[1][1] - corners[0][1])
side_c = (corners[2][0] - corners[3][0], corners[2][1] - corners[3][1])
assert abs(side_a[0] - side_c[0]) <= TOL
assert abs(side_a[1] - side_c[1]) <= TOL
def test_relu_after_a_matrix_is_not_linear():
M = compose(rotation(math.radians(30)), shear_x(1.5))
def relu_layer(point):
return tuple(max(0.0, c) for c in apply(M, point))
assert not is_linear(relu_layer, (1.0, -1.0), (0.5, 2.0), 3.0, TOL)
def test_the_area_factor_of_a_stack_is_the_product_of_its_factors():
layers = [scaling(2.0, 3.0), shear_x(4.0), reflection_in_x_axis()]
combined = layers[0]
for layer in layers[1:]:
combined = compose(layer, combined)
expected = 1.0
for layer in layers:
expected *= determinant(layer)
assert abs(determinant(combined) - expected) <= TOL
assert abs(expected + 6.0) <= TOL # 6 * 1 * -1, and the sign says it flipped
examples/transforms.py (13714 bytes)
"""Linear transformations of the plane, built from nothing but arithmetic.
The reference implementation. Everything here works on plain Python lists and
tuples, uses only `math` from the standard library, and never imports NumPy --
so you can read every line and see exactly what a transformation is doing. The
scripts beside this file then check all of it against NumPy, which is the only
way to know that "I wrote it myself" and "it is right" are both true.
The one idea the whole module is built on:
A matrix IS a function. Its columns are where the basis vectors land.
Column 0 is where (1, 0) goes. Column 1 is where (0, 1) goes. Everything else
follows, because every vector (x, y) is x * (1, 0) + y * (0, 1), and a linear
transformation is exactly one that keeps that combination intact.
"""
from __future__ import annotations
import math
Point = tuple[float, float]
Matrix = list[list[float]]
class SingularMatrix(ValueError):
"""Raised when a matrix has no inverse.
Subclasses ValueError deliberately, to match NumPy: `numpy.linalg.inv` on a
matrix with no inverse raises `numpy.linalg.LinAlgError`, and that class is
itself a subclass of ValueError. So `except ValueError` catches both, and
code written against one behaves the same against the other.
"""
# -- Reading a matrix, and reading it back -------------------------------------
def columns_of(matrix: Matrix) -> tuple[Point, Point]:
"""Return the two columns as points: (where e1 lands, where e2 lands).
A 2 by 2 matrix is written as rows -- [[a, b], [c, d]] -- but it MEANS its
columns. Column 0 is (a, c) and column 1 is (b, d). Half of all confusion
about transformation matrices is reading (a, b) as a landing place when it
is a row.
"""
(a, b), (c, d) = matrix
return (a, c), (b, d)
def from_landings(e1_lands_at: Point, e2_lands_at: Point) -> Matrix:
"""Build the matrix that sends (1, 0) and (0, 1) to the two given points.
This is the whole day in four lines. If you can see where the basis vectors
land, you can write the matrix down, and the matrix then tells you where
every other vector lands without you having to look at the picture again.
"""
(a, c), (b, d) = e1_lands_at, e2_lands_at
return [[a, b], [c, d]]
# -- Applying and composing ----------------------------------------------------
def apply(matrix: Matrix, point: Point) -> Point:
"""Send one point through the transformation.
Written the way the idea is stated rather than the way a textbook writes
it: the answer is x lots of the first column plus y lots of the second.
That is the same arithmetic as the usual row-by-row rule, but it says out
loud why the columns are the landing places.
"""
x, y = point
(e1x, e1y), (e2x, e2y) = columns_of(matrix)
return (x * e1x + y * e2x, x * e1y + y * e2y)
def compose(second: Matrix, first: Matrix) -> Matrix:
"""Return the single matrix that does `first` and then `second`.
Note the argument order, and note that it is not an accident. Written out,
applying `first` and then `second` to a vector v is
second @ (first @ v)
and the matrix that does both in one step is `second @ first` -- the one
that happens FIRST is written on the RIGHT, because it is the one standing
next to the vector. Reading a product right to left is not a quirk to
memorise; it is what the notation means.
The product is built one column at a time, which is again the day's idea:
column 0 of the answer is wherever (1, 0) ends up after both steps.
"""
e1, e2 = columns_of(first)
return from_landings(apply(second, e1), apply(second, e2))
def identity() -> Matrix:
"""The do-nothing transformation: e1 stays at (1, 0), e2 stays at (0, 1)."""
return [[1.0, 0.0], [0.0, 1.0]]
# -- The four standard transformations, each DERIVED from its landings ---------
def scaling(sx: float, sy: float) -> Matrix:
"""Stretch by sx horizontally and sy vertically.
Derivation: (1, 0) is one step right, and stretching horizontally by sx
makes it sx steps right, so it lands at (sx, 0). (0, 1) is one step up and
lands at (0, sy). Write those two down as columns and you have the matrix.
"""
return from_landings((sx, 0.0), (0.0, sy))
def reflection_in_x_axis() -> Matrix:
"""Mirror the plane in the horizontal axis: up becomes down.
Derivation: (1, 0) already lies on the mirror line, so it does not move.
(0, 1) is one step up, and its mirror image is one step down, at (0, -1).
"""
return from_landings((1.0, 0.0), (0.0, -1.0))
def reflection_in_y_axis() -> Matrix:
"""Mirror the plane in the vertical axis: right becomes left."""
return from_landings((-1.0, 0.0), (0.0, 1.0))
def shear_x(k: float) -> Matrix:
"""Push the plane sideways by k times its height.
Derivation: a point's sideways push is proportional to how high it is.
(1, 0) has height 0, so it is pushed by nothing and does not move. (0, 1)
has height 1, so it is pushed k to the right and lands at (k, 1).
This is the transformation to picture when someone says a deck of cards
slid sideways: the bottom card stays put, every card above it slides
further, and the deck's volume never changes.
"""
return from_landings((1.0, 0.0), (k, 1.0))
def shear_y(k: float) -> Matrix:
"""Push the plane upwards by k times its horizontal distance."""
return from_landings((1.0, k), (0.0, 1.0))
def rotation(theta: float) -> Matrix:
"""Turn the whole plane anticlockwise by theta RADIANS about the origin.
Derivation, which needs the unit circle and nothing else. Draw a circle of
radius 1 around the origin. Start at (1, 0) and walk anticlockwise around
the rim until you have turned through an angle theta. The two numbers that
name where you now stand are, by definition, the cosine and the sine of
theta: cos(theta) across, sin(theta) up. That is what those two functions
ARE -- the coordinates of a point on the unit circle -- and every identity
about them is a fact about that picture.
So (1, 0) lands at (cos(theta), sin(theta)).
(0, 1) is (1, 0) already turned a quarter of a turn anticlockwise, and
turning it a further theta puts it a quarter turn ahead of the first
landing place. A quarter turn anticlockwise sends any point (x, y) to
(-y, x) -- push it round and the across-ness becomes up-ness. Applying
that to (cos(theta), sin(theta)) gives (-sin(theta), cos(theta)).
Write the two landings as columns:
[[cos(theta), -sin(theta)],
[sin(theta), cos(theta)]]
which is the rotation matrix, derived rather than remembered.
Radians, briefly: an angle measured as the distance you walked around the
rim of that unit circle. A full turn is the whole circumference, 2 * pi. So
a quarter turn is pi / 2, and `math.radians(90)` converts if you would
rather think in degrees.
"""
cos_t, sin_t = math.cos(theta), math.sin(theta)
return from_landings((cos_t, sin_t), (-sin_t, cos_t))
# -- Determinant, inverse, rank ------------------------------------------------
def determinant(matrix: Matrix) -> float:
"""The factor by which the transformation multiplies area, sign included.
For a 2 by 2 matrix [[a, b], [c, d]] the answer is a*d - b*c, and this
function computes that exactly -- one multiply, one multiply, one subtract.
No rearrangement, so no rounding beyond the inputs themselves.
What the number means:
* its SIZE is the area factor. A unit square of area 1 comes out with
area |determinant|.
* its SIGN is the orientation. Positive means the plane was not flipped
over; negative means it was, and a shape listed anticlockwise comes out
listed clockwise.
* ZERO means the plane was flattened onto a line or onto a point. Area
1 became area 0, information was destroyed, and nothing can undo it.
"""
(a, b), (c, d) = matrix
return a * d - b * c
def inverse(matrix: Matrix) -> Matrix:
"""The transformation that undoes this one.
Derived by asking the only question that matters: which matrix, composed
with this one, leaves everything where it started? For 2 by 2 the answer is
the standard formula, one over the determinant times [[d, -b], [-c, a]].
It exists precisely when the determinant is not zero, which is the same
sentence as "precisely when no area was destroyed". If two different
starting points landed on the same place, no rule could send that place
back to both of them, so there is nothing to return.
"""
det = determinant(matrix)
if det == 0.0:
raise SingularMatrix(
"Singular matrix: the determinant is 0, so this transformation "
"collapses the plane and cannot be undone"
)
(a, b), (c, d) = matrix
return [[d / det, -b / det], [-c / det, a / det]]
def rank(matrix: Matrix, tol: float = 1e-12) -> int:
"""How many dimensions survive the transformation.
Plain language: feed the whole plane in, and look at what comes out. If the
output fills the plane, the rank is 2. If it is squashed onto a line, the
rank is 1. If everything lands on the origin, the rank is 0.
For a 2 by 2 matrix that reads straight off the columns. If the determinant
is not zero the two columns point in genuinely different directions and
between them they reach everywhere, so the rank is 2. If the determinant is
zero but at least one column is not the zero vector, everything lands on
the line through that column, so the rank is 1. If both columns are zero,
everything lands on the origin and the rank is 0.
`tol` is here because the determinant of a matrix built from cosines will
rarely be exactly 0.0, and asking `== 0` of a computed float is how you get
a rank of 2 for a matrix that has plainly collapsed.
"""
if abs(determinant(matrix)) > tol:
return 2
if any(abs(entry) > tol for row in matrix for entry in row):
return 1
return 0
# -- Polygons ------------------------------------------------------------------
def transform_polygon(matrix: Matrix, polygon: list[Point]) -> list[Point]:
"""Send every corner of a polygon through the transformation.
Corners are enough. A linear transformation sends straight lines to
straight lines, so the edges take care of themselves -- which is exactly
the property that makes these transformations cheap, and exactly the
property that stops a stack of them ever drawing a curve.
"""
return [apply(matrix, point) for point in polygon]
def signed_area(polygon: list[Point]) -> float:
"""The area of a polygon, negative if its corners run clockwise.
The shoelace formula: walk the corners in order, and for each edge add
x_here * y_next - x_next * y_here. Halve the total. It is called the
shoelace formula because the cross-multiplied pairs criss-cross like the
lacing on a shoe.
The sign is the part this lab uses. List the unit square anticlockwise and
the signed area is +1; transform it by a matrix with a negative determinant
and the signed area comes out negative, because the corners now run the
other way round. That is what "the plane was flipped over" means when it is
measured rather than described.
"""
total = 0.0
count = len(polygon)
for i in range(count):
x_here, y_here = polygon[i]
x_next, y_next = polygon[(i + 1) % count]
total += x_here * y_next - x_next * y_here
return total / 2.0
# -- Linearity -----------------------------------------------------------------
def preserves_addition(
func, u: Point, v: Point, tol: float = 1e-12
) -> tuple[bool, Point, Point]:
"""Test whether func(u + v) equals func(u) + func(v).
The first half of the definition of linear. Returns the verdict and both
sides, because when a function fails this test the interesting part is not
that it failed -- it is by how much, and whether the gap is the same every
time.
"""
together = func((u[0] + v[0], u[1] + v[1]))
separately = tuple(a + b for a, b in zip(func(u), func(v)))
ok = all(abs(a - b) <= tol for a, b in zip(together, separately))
return ok, together, separately # type: ignore[return-value]
def preserves_scaling(
func, u: Point, s: float, tol: float = 1e-12
) -> tuple[bool, Point, Point]:
"""Test whether func(s * u) equals s * func(u).
The second half of the definition. A function needs BOTH halves to be
linear, and a function that fails either one cannot be written as a matrix,
no matter how simple it looks.
"""
scaled_first = func((s * u[0], s * u[1]))
scaled_after = tuple(s * component for component in func(u))
ok = all(abs(a - b) <= tol for a, b in zip(scaled_first, scaled_after))
return ok, scaled_first, scaled_after # type: ignore[return-value]
def is_linear(func, u: Point, v: Point, s: float, tol: float = 1e-12) -> bool:
"""Both halves at once, on one chosen pair of vectors and one scalar.
An honest caveat, and it matters: passing this on one example does not
prove a function is linear. The definition quantifies over EVERY pair of
vectors and every scalar, and no finite number of examples can settle that.
What a test like this does well is the other direction -- a single failure
is a complete disproof, and that is how it is used here.
"""
return (
preserves_addition(func, u, v, tol)[0]
and preserves_scaling(func, u, s, tol)[0]
)
metadata.yml (2419 bytes)
lesson_id: D102
day: 102
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-102-linear-transformations
- 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_columns_are_landings.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_building_the_transformations.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_linear_or_not.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_composition_and_order.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_determinant_inverse_rank.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_the_limit_of_linear.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 -> 64 checks, 0 failure(s), exit 0; pytest examples -> 80 passed; pytest starter -> 1 passed, 53 skipped on an untouched checkout, and 54 passed against a fully solved copy of starter/ kept outside the lab. All six 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 one expectation deliberately swapped for the naive belief that cos(pi/2) is exactly 0.0, 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. Two numbers in the captured output are real floating-point artefacts and are asserted rather than hidden: cos(pi/2) is 6.123233995736766e-17 rather than 0.0, and numpy.linalg.det on [[3, -1], [1, 2]] returns 7.000000000000001 where the direct a*d-b*c formula returns exactly 7.0.'
requirements/README.md (4351 bytes)
# Dependencies for the Day 102 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` | The library your from-scratch transformations are checked against: the `@` operator for matrix products, `numpy.linalg.det`, `numpy.linalg.inv`, `numpy.linalg.matrix_rank`, and the `LinAlgError` a singular matrix raises. |
| `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 six reference scripts run on
those two packages plus the standard library — and the from-scratch module
itself imports nothing but `math`.
## Why the from-scratch code deliberately does not use NumPy
`examples/transforms.py` and `starter/transforms.py` are written with plain
lists and tuples on purpose. If your `rotation` function returned a NumPy array
built by a NumPy helper, then checking it against NumPy would be checking NumPy
against itself. Writing the arithmetic yourself and *then* having a mature
library agree with you is worth more than either half alone.
## Why numpy is pinned
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.
There is one place where the version could matter. `numpy.linalg.det` on the
matrix `[[3, -1], [1, 2]]` returns `7.000000000000001` on this machine with
this version — one bit away from the exact 7 the direct formula gives. That is
a property of the factorisation it uses and of the underlying linear algebra
library, and it is not guaranteed to be identical everywhere. The lab handles
this honestly: it asserts that NumPy's answer is *within 1e-14 of 7* and that
the from-scratch answer is *exactly 7*, rather than pinning NumPy's last digit.
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.
## 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
More than you might expect, and less than you might fear. Exercise 1 — writing
all ten transformation functions — needs nothing but `math` from the standard
library, and you can complete the whole of it on a bare `python3`. What you
lose is every cross-check: the tests that confirm your `compose` matches `@`,
your `inverse` matches `numpy.linalg.inv`, and your `rank` matches
`numpy.linalg.matrix_rank`, plus the exercise on the exception a singular
matrix raises. Those parts cannot be faked and the lab does not pretend
otherwise.
requirements/requirements.txt (27 bytes)
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (7139 bytes)
# Day 102 lab — Where Do the Basis Vectors Land?
One idea holds this whole lab together:
> **A matrix IS a function, and its columns are where the basis vectors land.**
If you know where `(1, 0)` and `(0, 1)` go, you know where everything goes —
because every vector `(x, y)` is `x * (1, 0) + y * (0, 1)`, and a linear
transformation is precisely one that keeps that combination intact.
Everything below is a consequence of that sentence. Work in order; each
exercise uses the one before it.
Check yourself at any point, from the **lab directory** (the one above this
file):
```bash
.venv/bin/pytest starter -q
```
Unattempted work is **skipped**, not failed. On an untouched checkout you will
see `1 passed, 53 skipped`. When it says `54 passed`, you are finished.
---
## Exercise 1 — `transforms.py` (ten functions)
Write the ten functions marked `raise NotImplementedError`. Each docstring
gives the derivation and a worked example you can check on paper. Use only
`math` from the standard library in this file — NumPy appears in the tests,
where it checks your work, which is the right way round.
| Step | Function | What it must do |
| --- | --- | --- |
| 1.1 | `from_landings` | Two landing places in, one matrix out. The landings are the **columns**. |
| 1.2 | `columns_of` | The exact inverse of 1.1. |
| 1.3 | `apply` | `x` lots of the first column plus `y` lots of the second. |
| 1.4 | `scaling` | Derive it: where does one step right go when the plane stretches? |
| 1.5 | `reflection_in_x_axis` | Derive it: what happens to a point that is already **on** the mirror line? |
| 1.6 | `shear_x` | Derive it: the sideways push is proportional to the **height**. |
| 1.7 | `rotation` | Derive it from the unit circle. The docstring defines cosine and sine from scratch if you have not met them. |
| 1.8 | `compose` | Column 0 is where `(1, 0)` ends up after **both** steps. Mind the argument order. |
| 1.9 | `determinant` | `a*d - b*c`, computed directly so whole numbers stay exact. |
| 1.10 | `inverse` | The undo. Raise `SingularMatrix` when the determinant is 0. |
Three helpers are written for you at the bottom of the file — `identity`,
`transform_polygon`, `signed_area` and `rank`. Read them; the tests use them.
**The gotcha in 1.8.** `compose(second, first)` returns the matrix that does
`first` and then `second`, so the step that happens **first** is written on the
**right**. That is not an arbitrary convention: applying `first` and then
`second` to a vector `v` is `second @ (first @ v)`, and `first` is the one
standing next to the vector.
---
## Exercise 2 — read a matrix off a picture (`answers.py`)
There is no picture file. The drawing is described in words in `shapes.py`,
because the skill being trained is going from *where did the basis vectors
land* to *what is the matrix*, and looking at a picture would let you skip the
step.
The drawing shows two things and only two things:
```
the arrow (1, 0) redrawn ending at ( 3, 1)
the arrow (0, 1) redrawn ending at (-1, 2)
```
Write the matrix. Then say where `(2, 1)` lands, without drawing anything.
Exercise 2.3 asks which **row** of that matrix is a landing place. Read the
question carefully before answering; it is the single most common mistake with
transformation matrices.
---
## Exercise 3 — the four standard transformations
Predict where specific points land under scaling, reflection, shear and a
quarter turn. Each answer is one line of arithmetic.
Exercise 3.6 is the one worth slowing down for. On paper, a quarter turn sends
`(1, 0)` to exactly `(0, 1)`. In binary floating point, `cos(pi / 2)` comes out
as `6.123233995736766e-17`, because `pi` cannot be stored exactly and the
cosine of the stored value is not the cosine of pi. **That is why every float
comparison in this lab uses a tolerance of `1e-12` and none of them use `==`.**
The number is not arbitrary: it sits about five orders of magnitude above that
rounding error and about four below the smallest quantity the lab cares about.
Exercise 3.7 asks for the one point no matrix anywhere can move. Once you see
why, half of exercise 4 answers itself.
---
## Exercise 4 — linear, and not linear
*Linear* means exactly two things:
```
T(u + v) = T(u) + T(v) it preserves addition
T(s * u) = s * T(u) it preserves scalar multiplication
```
Both, for every `u`, `v` and `s`. Nothing else.
With `M = [[2, 0], [0, 3]]`, `b = (1, 1)`, `u = (1, 2)`, `v = (3, -1)`, `s = 5`,
you check both properties for `T(v) = M @ v` and for `f(v) = M @ v + b`.
`f` fails both. Compute the gap in 4.5 before you read any further explanation
— the size of the gap is a recognisable quantity, and recognising it is the
exercise.
This is not a curiosity. It is the reason a neural network layer is written
`X @ W + b` with the bias kept separate rather than folded into the matrix, and
the reason the word *affine* exists.
---
## Exercise 5 — composition and order
`A = shear_x(2)`, `B = rotation(pi / 2)`. You shear first, then rotate.
Which product is that? Write it out. Then decide whether the other order gives
the same matrix. It does not, and 5.3 wants you to have checked rather than
assumed.
5.4 is a freebie with a real consequence behind it: determinants multiply under
composition, so if any step in a pipeline has determinant 0, the whole pipeline
does, and nothing downstream can recover what that step destroyed.
---
## Exercise 6 — determinant, area, orientation, rank and the inverse
The determinant is introduced here the way it is actually useful: send the unit
square through the transformation and measure the area of what comes out. That
number, **with its sign**, is the determinant.
- 6.1–6.4: positive determinants, and one negative one. What does the sign say?
- 6.5–6.7: `[[1, 2], [2, 4]]`. Look at its two columns before predicting
anything. Where does everything end up?
- 6.8: name the exception class `numpy.linalg.inv` raises on it. Give the class
itself, not a string. `numpy` is already imported at the top of `answers.py`.
- 6.9–6.10: two inverses you can write down without the formula, if you think
about what would undo each transformation.
---
## When you are done
Read the reference. Each script prints its working and asserts every claim it
makes, so nothing in it is decoration:
```bash
cd examples
../.venv/bin/python3 01_columns_are_landings.py
../.venv/bin/python3 02_building_the_transformations.py
../.venv/bin/python3 03_linear_or_not.py
../.venv/bin/python3 04_composition_and_order.py
../.venv/bin/python3 05_determinant_inverse_rank.py
../.venv/bin/python3 06_the_limit_of_linear.py
cd ..
```
Script 06 is the payoff, and it is worth reading even if you stop everything
else. It shows that a linear transformation always fixes the origin, always
sends straight lines to straight lines, and that a stack of twenty of them
collapses into a single 2 by 2 matrix. Depth bought nothing. That is the
concrete, measured reason a non-linear activation function sits between the
layers of a network — not a rule to memorise, a limitation you can watch
happening.
starter/answers.py (6375 bytes)
"""Exercises 2 to 6 -- your predictions. Work them out BEFORE running anything.
Every one of these can be done on paper in under a minute. That is the point:
a lab about transformations whose answers you cannot check by hand is a lab
that teaches you to trust output.
Replace each `None` with your answer. Anything still `None` is SKIPPED by the
test suite rather than failed, so your score only ever counts work you actually
attempted.
Check yourself from the LAB DIRECTORY:
.venv/bin/pytest starter -q
"""
# Imported for you: exercise 6.8 asks for an exception CLASS, and the one it is
# looking for lives at numpy.linalg.LinAlgError.
import numpy
# =============================================================================
# Exercise 2 -- reading a matrix off a picture
# =============================================================================
#
# The picture shows the arrow (1, 0) redrawn ending at (3, 1), and the arrow
# (0, 1) redrawn ending at (-1, 2). Nothing else.
# 2.1 Write the matrix, as a list of two ROWS. Careful: the landing places are
# the COLUMNS, so they are read downwards, not across.
# Example of the format: [[1.0, 0.0], [0.0, 1.0]]
PICTURE_MATRIX = None
# 2.2 Where does (2, 1) land? Work it out as 2 lots of the first landing place
# plus 1 lot of the second, and give an (x, y) tuple of floats.
PICTURE_SENDS_2_1_TO = None
# 2.3 Which of the two ROWS of that matrix is a landing place for a basis
# vector -- 0, 1, or neither? Answer with the integer 0, the integer 1, or
# the string "neither".
WHICH_ROW_IS_A_LANDING = None
# =============================================================================
# Exercise 3 -- the four standard transformations
# =============================================================================
# 3.1 scaling(2, 3) applied to (1, 1). An (x, y) tuple.
SCALE_SENDS_1_1_TO = None
# 3.2 reflection_in_x_axis() applied to (2, 3). An (x, y) tuple.
FLIP_SENDS_2_3_TO = None
# 3.3 shear_x(2) applied to (1, 1). An (x, y) tuple.
# Remember: the sideways push is proportional to the HEIGHT.
SHEAR_SENDS_1_1_TO = None
# 3.4 shear_x(2) applied to (5, 0). An (x, y) tuple. Think before you compute.
SHEAR_SENDS_5_0_TO = None
# 3.5 A quarter turn anticlockwise, rotation(pi / 2), applied to (1, 0).
# Give the answer you would write on paper, as an (x, y) tuple. The test
# compares with a tolerance of 1e-12 rather than with ==, and exercise 3.6
# is about why.
QUARTER_TURN_SENDS_1_0_TO = None
# 3.6 In binary floating point, is math.cos(math.pi / 2) exactly 0.0?
# True or False.
COS_OF_QUARTER_TURN_IS_EXACTLY_ZERO = None
# 3.7 Every matrix in this lab sends one particular point to itself, no matter
# what the four entries are. Which point? An (x, y) tuple.
THE_POINT_NO_MATRIX_CAN_MOVE = None
# =============================================================================
# Exercise 4 -- linearity, and the function that fails it
# =============================================================================
#
# T(v) = M @ v with M = [[2, 0], [0, 3]]
# f(v) = M @ v + b with b = (1, 1)
# u = (1, 2), v = (3, -1), s = 5
# 4.1 T(u + v). An (x, y) tuple.
T_OF_U_PLUS_V = None
# 4.2 T(u) + T(v). An (x, y) tuple.
T_OF_U_PLUS_T_OF_V = None
# 4.3 f(u + v). An (x, y) tuple.
F_OF_U_PLUS_V = None
# 4.4 f(u) + f(v). An (x, y) tuple.
F_OF_U_PLUS_F_OF_V = None
# 4.5 Subtract 4.3 from 4.4. The gap is one recognisable quantity -- which?
# An (x, y) tuple.
THE_GAP_BETWEEN_THEM = None
# 4.6 Is f linear? True or False.
F_IS_LINEAR = None
# 4.7 f((0, 0)). An (x, y) tuple -- and notice what it tells you about 4.6
# without needing 4.1 to 4.5 at all.
F_OF_THE_ORIGIN = None
# =============================================================================
# Exercise 5 -- composition and order
# =============================================================================
#
# A = shear_x(2), B = rotation(pi / 2). You shear FIRST and then rotate.
# 5.1 Which expression is the single matrix that does shear-then-rotate?
# Answer with the string "compose(B, A)" or the string "compose(A, B)".
SHEAR_THEN_ROTATE_IS = None
# 5.2 Write that matrix out, as two rows of floats. Work it out from where the
# two basis vectors end up after both steps -- it is easier than the
# row-by-column rule and it is the same answer.
SHEAR_THEN_ROTATE_MATRIX = None
# 5.3 Do the two orders give the same matrix? True or False.
BOTH_ORDERS_AGREE = None
# 5.4 det(A) is 1 and det(B) is 1. What is the determinant of the composite?
# A float.
DET_OF_THE_COMPOSITE = None
# =============================================================================
# Exercise 6 -- determinant, area, orientation, rank and the inverse
# =============================================================================
# 6.1 The unit square has area 1. What area does scaling(2, 3) give it?
# A float.
AREA_AFTER_SCALING = None
# 6.2 What is the SIGNED area of the unit square after reflection in the x
# axis? A float. Mind the sign; that is the whole question.
SIGNED_AREA_AFTER_REFLECTION = None
# 6.3 What does a negative determinant tell you? Answer with one of the
# strings: "the shape got smaller", "the plane was flipped over",
# "the transformation cannot be undone".
A_NEGATIVE_DETERMINANT_MEANS = None
# 6.4 What is the determinant of shear_x(2)? A float, and it should surprise
# you slightly less once you have drawn the sheared square.
DET_OF_SHEAR = None
# 6.5 COLLAPSE_MATRIX is [[1, 2], [2, 4]]. Look at its two columns. What is its
# determinant? A float.
DET_OF_COLLAPSE = None
# 6.6 What is its rank -- how many dimensions survive? The integer 0, 1 or 2.
RANK_OF_COLLAPSE = None
# 6.7 Every vector it touches lands on one line through the origin. The line is
# y = m * x. What is m? A float.
COLLAPSE_LANDS_EVERYTHING_ON_THE_LINE_Y_EQUALS = None
# 6.8 numpy.linalg.inv of that matrix raises an exception. Which class?
# Give the class itself, not a string, for example: ValueError
# `numpy` is already imported for you below.
COLLAPSE_INVERSE_EXCEPTION = None
# 6.9 The inverse of shear_x(2) is another shear. With what k? A float.
INVERSE_OF_SHEAR_IS_SHEAR_WITH_K = None
# 6.10 The inverse of scaling(2, 4), as two rows of floats.
INVERSE_OF_SCALING_2_4 = None
starter/conftest.py (1072 bytes)
"""Make this directory's own transforms.py the one its tests import.
Both `examples/` and `starter/` contain modules called `transforms` and
`shapes`, 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 `transforms` was seen first and then 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.
So: put this directory first on the import path, and drop any already-imported
`transforms` or `shapes` 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 ("transforms", "shapes", "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/shapes.py (2368 bytes)
"""The data this lab works on. Read it; you do not need to change it.
Everything here is invented. A 2 by 2 matrix has four numbers in it, and the
whole point of the day is that you can check every one of them on paper.
Points are `(x, y)` pairs. Polygons are lists of corners in counter-clockwise
order, because the SIGN of a polygon's area depends on which way round its
corners are listed, and that sign is what tells you whether a transformation
turned the plane over.
"""
# -- The standard basis -------------------------------------------------------
E1 = (1.0, 0.0)
E2 = (0.0, 1.0)
# -- The unit square, listed counter-clockwise from the origin ----------------
UNIT_SQUARE = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
# -- The flag ------------------------------------------------------------------
#
# A deliberately lopsided shape, so you can tell at a glance whether it has
# been turned, stretched, sheared or mirrored: an L lying on its back.
FLAG = [
(0.0, 0.0),
(2.0, 0.0),
(2.0, 0.5),
(0.5, 0.5),
(0.5, 2.0),
(0.0, 2.0),
]
# -- The picture you read a matrix off of --------------------------------------
#
# Exercise 2 describes a drawing in words rather than showing it, because the
# skill is going from "where did the basis vectors land" to "what is the
# matrix", and a picture would let you skip the step. The drawing shows:
#
# the arrow (1, 0) redrawn ending at (3, 1)
# the arrow (0, 1) redrawn ending at (-1, 2)
PICTURE_E1_LANDS_AT = (3.0, 1.0)
PICTURE_E2_LANDS_AT = (-1.0, 2.0)
# -- The transformations the exercises use ------------------------------------
SCALE_X, SCALE_Y = 2.0, 3.0
SHEAR_K = 2.0
# -- The collapse --------------------------------------------------------------
#
# Look at the two columns before you predict anything about this one.
COLLAPSE_MATRIX = [[1.0, 2.0], [2.0, 4.0]]
# -- Tolerance -----------------------------------------------------------------
#
# Why 1e-12 and not equality: cos(pi / 2) is 6.123233995736766e-17 in binary
# floating point, not 0.0, and sin(pi / 6) is 0.49999999999999994, not 0.5.
# Both are about 1e-17 from the exact answer. 1e-12 sits five orders of
# magnitude above that error and about four below the smallest quantity this
# lab cares about, so it accepts the rounding and would still catch a genuinely
# wrong answer.
TOL = 1e-12
starter/test_starter.py (11339 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, and `shapes.py`
explains why that number and not equality.
"""
import math
import numpy as np
import pytest
import answers
import shapes
from transforms import (
SingularMatrix,
apply,
columns_of,
compose,
determinant,
from_landings,
identity,
inverse,
reflection_in_x_axis,
rotation,
scaling,
shear_x,
signed_area,
transform_polygon,
)
TOL = shapes.TOL
def written(fn, *args, **kwargs):
"""Run part of your work, 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
def close(a, b, tol=TOL):
"""Elementwise closeness for points and for 2 by 2 matrices."""
if isinstance(a[0], (list, tuple)):
return all(close(ra, rb, tol) for ra, rb in zip(a, b))
return all(abs(x - y) <= tol for x, y in zip(a, b))
# -- 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 identity() == [[1.0, 0.0], [0.0, 1.0]], "the written-for-you helpers load"
assert abs(signed_area(shapes.UNIT_SQUARE) - 1.0) <= TOL
# -- Exercise 1: your transforms.py ------------------------------------------
#
# Every test below runs its whole body inside `written(...)`, so a test is
# skipped if ANY function it needs is still unwritten -- not just the first
# one. Arguments in Python are evaluated before the call, so gating on one
# function while calling another inside the arguments would let a
# NotImplementedError escape and be reported as a failure. It would say
# "attempted and wrong" about work you had not attempted, which is precisely
# the lie this suite exists to avoid.
def test_1_1_from_landings():
M = written(lambda: from_landings((3.0, 1.0), (-1.0, 2.0)))
assert M == [[3.0, -1.0], [1.0, 2.0]], (
"the landing places are the COLUMNS, so they read downwards"
)
def test_1_2_columns_of():
e1, e2 = written(lambda: columns_of([[3.0, -1.0], [1.0, 2.0]]))
assert tuple(e1) == (3.0, 1.0)
assert tuple(e2) == (-1.0, 2.0)
def test_1_2_columns_of_undoes_from_landings():
def check():
for a, b in [((2.0, -5.0), (0.5, 7.0)), ((0.0, 1.0), (1.0, 0.0))]:
got_a, got_b = columns_of(from_landings(a, b))
assert tuple(got_a) == a and tuple(got_b) == b
written(check)
def test_1_3_apply():
assert written(lambda: apply([[3.0, -1.0], [1.0, 2.0]], (2.0, 1.0))) == (5.0, 4.0)
def test_1_3_apply_to_a_basis_vector_reads_a_column():
M = [[3.0, -1.0], [1.0, 2.0]]
assert tuple(written(lambda: apply(M, shapes.E1))) == (3.0, 1.0)
assert tuple(apply(M, shapes.E2)) == (-1.0, 2.0)
def test_1_3_apply_matches_numpy():
def check():
M = [[3.0, -1.0], [1.0, 2.0]]
for point in [(2.0, 1.0), (-1.5, 4.0), (0.0, 0.0)]:
assert close(apply(M, point), (np.array(M) @ np.array(point)).tolist())
written(check)
def test_1_4_scaling():
assert written(lambda: scaling(2.0, 3.0)) == [[2.0, 0.0], [0.0, 3.0]]
assert tuple(written(lambda: apply(scaling(2.0, 3.0), (1.0, 1.0)))) == (2.0, 3.0)
def test_1_5_reflection():
assert written(reflection_in_x_axis) == [[1.0, 0.0], [0.0, -1.0]]
assert tuple(written(lambda: apply(reflection_in_x_axis(), (5.0, 0.0)))) == (
5.0,
0.0,
), "a point on the mirror line cannot move"
def test_1_6_shear():
assert written(lambda: shear_x(2.0)) == [[1.0, 2.0], [0.0, 1.0]]
assert tuple(written(lambda: apply(shear_x(2.0), (5.0, 0.0)))) == (5.0, 0.0), (
"height 0 means no sideways push at all"
)
assert tuple(apply(shear_x(2.0), (1.0, 1.0))) == (3.0, 1.0)
def test_1_7_rotation_quarter_turn():
Q = written(lambda: rotation(math.pi / 2))
assert close(Q, [[0.0, -1.0], [1.0, 0.0]]), (
"compared with a tolerance, because cos(pi / 2) is not exactly 0.0"
)
def test_1_7_rotation_thirty_degrees():
R = written(lambda: rotation(math.radians(30)))
assert close(R, [[math.sqrt(3) / 2, -0.5], [0.5, math.sqrt(3) / 2]])
def test_1_7_rotation_preserves_length():
def check():
for point in [(3.0, 4.0), (-2.0, 7.0)]:
turned = apply(rotation(0.9), point)
assert abs(math.hypot(*point) - math.hypot(*turned)) <= TOL
written(check)
def test_1_8_compose_matches_the_hand_worked_product():
C = written(lambda: compose(rotation(math.pi / 2), shear_x(2.0)))
assert close(C, [[0.0, -1.0], [1.0, 2.0]])
def test_1_8_compose_reproduces_two_separate_steps():
def check():
A, B = shear_x(2.0), rotation(math.pi / 2)
two_steps = transform_polygon(B, transform_polygon(A, shapes.FLAG))
at_once = transform_polygon(compose(B, A), shapes.FLAG)
for p, q in zip(two_steps, at_once):
assert close(p, q)
written(check)
def test_1_8_compose_matches_numpy_matmul():
def check():
A, B = shear_x(2.0), rotation(math.pi / 2)
assert close(compose(B, A), (np.array(B) @ np.array(A)).tolist())
written(check)
def test_1_9_determinant():
assert written(lambda: determinant([[3.0, -1.0], [1.0, 2.0]])) == 7.0, (
"compute a*d - b*c directly; on whole numbers it should be exact"
)
assert determinant([[1.0, 2.0], [2.0, 4.0]]) == 0.0
assert determinant(identity()) == 1.0
def test_1_9_determinant_is_the_area_factor():
def check():
for M, expected in [(scaling(2.0, 3.0), 6.0), (reflection_in_x_axis(), -1.0)]:
area = signed_area(transform_polygon(M, shapes.UNIT_SQUARE))
assert abs(area - expected) <= TOL
assert abs(determinant(M) - expected) <= TOL
written(check)
def test_1_10_inverse():
assert written(lambda: inverse([[1.0, 2.0], [0.0, 1.0]])) == [[1.0, -2.0], [0.0, 1.0]]
def test_1_10_inverse_undoes_the_original():
def check():
for M in [shear_x(2.0), scaling(2.0, 3.0), [[3.0, -1.0], [1.0, 2.0]]]:
assert close(compose(inverse(M), M), identity())
written(check)
def test_1_10_inverse_refuses_a_collapse():
written(lambda: inverse(identity()))
with pytest.raises(SingularMatrix):
inverse(shapes.COLLAPSE_MATRIX)
def test_1_10_that_refusal_is_catchable_as_a_ValueError():
written(lambda: inverse(identity()))
with pytest.raises(ValueError):
inverse(shapes.COLLAPSE_MATRIX)
def test_1_10_inverse_matches_numpy():
def check():
for M in [shear_x(2.0), scaling(2.0, 3.0), [[3.0, -1.0], [1.0, 2.0]]]:
assert close(inverse(M), np.linalg.inv(np.array(M)).tolist())
written(check)
# -- Exercise 2: reading a matrix off a picture ------------------------------
def test_2_1_picture_matrix():
assert predicted("PICTURE_MATRIX") == [[3.0, -1.0], [1.0, 2.0]]
def test_2_2_where_2_1_lands():
assert tuple(predicted("PICTURE_SENDS_2_1_TO")) == (5.0, 4.0)
def test_2_3_neither_row_is_a_landing():
assert predicted("WHICH_ROW_IS_A_LANDING") == "neither"
# -- Exercise 3: the four standard transformations ---------------------------
def test_3_1_scaling():
assert tuple(predicted("SCALE_SENDS_1_1_TO")) == (2.0, 3.0)
def test_3_2_reflection():
assert tuple(predicted("FLIP_SENDS_2_3_TO")) == (2.0, -3.0)
def test_3_3_shear():
assert tuple(predicted("SHEAR_SENDS_1_1_TO")) == (3.0, 1.0)
def test_3_4_shear_on_the_axis():
assert tuple(predicted("SHEAR_SENDS_5_0_TO")) == (5.0, 0.0)
def test_3_5_quarter_turn():
assert close(tuple(predicted("QUARTER_TURN_SENDS_1_0_TO")), (0.0, 1.0))
def test_3_6_cosine_is_not_exactly_zero():
assert predicted("COS_OF_QUARTER_TURN_IS_EXACTLY_ZERO") is False
assert math.cos(math.pi / 2) != 0.0
def test_3_7_the_origin_is_fixed_by_every_matrix():
assert tuple(predicted("THE_POINT_NO_MATRIX_CAN_MOVE")) == (0.0, 0.0)
# -- Exercise 4: linearity ----------------------------------------------------
def test_4_1_T_of_u_plus_v():
assert tuple(predicted("T_OF_U_PLUS_V")) == (8.0, 3.0)
def test_4_2_T_of_u_plus_T_of_v():
assert tuple(predicted("T_OF_U_PLUS_T_OF_V")) == (8.0, 3.0)
def test_4_3_f_of_u_plus_v():
assert tuple(predicted("F_OF_U_PLUS_V")) == (9.0, 4.0)
def test_4_4_f_of_u_plus_f_of_v():
assert tuple(predicted("F_OF_U_PLUS_F_OF_V")) == (10.0, 5.0)
def test_4_5_the_gap_is_exactly_b():
assert tuple(predicted("THE_GAP_BETWEEN_THEM")) == (1.0, 1.0)
def test_4_6_f_is_not_linear():
assert predicted("F_IS_LINEAR") is False
def test_4_7_f_moves_the_origin():
assert tuple(predicted("F_OF_THE_ORIGIN")) == (1.0, 1.0)
# -- Exercise 5: composition and order ---------------------------------------
def test_5_1_which_order():
assert predicted("SHEAR_THEN_ROTATE_IS") == "compose(B, A)"
def test_5_2_the_composite_matrix():
assert close(predicted("SHEAR_THEN_ROTATE_MATRIX"), [[0.0, -1.0], [1.0, 2.0]])
def test_5_3_order_matters():
assert predicted("BOTH_ORDERS_AGREE") is False
def test_5_4_determinants_multiply():
assert abs(predicted("DET_OF_THE_COMPOSITE") - 1.0) <= TOL
# -- Exercise 6: determinant, rank and the inverse ---------------------------
def test_6_1_area_after_scaling():
assert abs(predicted("AREA_AFTER_SCALING") - 6.0) <= TOL
def test_6_2_signed_area_after_reflection():
assert abs(predicted("SIGNED_AREA_AFTER_REFLECTION") + 1.0) <= TOL
def test_6_3_what_a_negative_determinant_means():
assert predicted("A_NEGATIVE_DETERMINANT_MEANS") == "the plane was flipped over"
def test_6_4_shear_preserves_area():
assert abs(predicted("DET_OF_SHEAR") - 1.0) <= TOL
def test_6_5_determinant_of_the_collapse():
assert abs(predicted("DET_OF_COLLAPSE")) <= TOL
def test_6_6_rank_of_the_collapse():
assert predicted("RANK_OF_COLLAPSE") == 1
assert int(np.linalg.matrix_rank(np.array(shapes.COLLAPSE_MATRIX))) == 1
def test_6_7_the_line_everything_lands_on():
m = predicted("COLLAPSE_LANDS_EVERYTHING_ON_THE_LINE_Y_EQUALS")
M = np.array(shapes.COLLAPSE_MATRIX)
for point in [(1.0, 0.0), (0.0, 1.0), (3.0, -1.0)]:
x, y = (M @ np.array(point)).tolist()
assert abs(y - m * x) <= TOL
def test_6_8_the_exception_numpy_raises():
cls = predicted("COLLAPSE_INVERSE_EXCEPTION")
assert cls is np.linalg.LinAlgError
with pytest.raises(cls):
np.linalg.inv(np.array(shapes.COLLAPSE_MATRIX))
def test_6_9_inverse_of_a_shear():
assert abs(predicted("INVERSE_OF_SHEAR_IS_SHEAR_WITH_K") + 2.0) <= TOL
def test_6_10_inverse_of_a_scaling():
assert close(predicted("INVERSE_OF_SCALING_2_4"), [[0.5, 0.0], [0.0, 0.25]])
starter/transforms.py (10587 bytes)
"""Exercise 1 -- your linear transformations, built from arithmetic alone.
Ten functions to write. Each one has a docstring saying exactly what it must
do, a worked example you can check on paper, and a `raise NotImplementedError`
to delete when you write it.
Check yourself as you go, from the LAB DIRECTORY:
.venv/bin/pytest starter -q
Anything you have not written yet is SKIPPED rather than failed. A skip means
"not attempted"; a failure means "attempted and wrong", and it prints your
answer beside the real one.
Use only `math` from the standard library in this file. NumPy appears in the
tests, where it checks your work -- which is the right way round. Writing it
yourself and then having a mature library agree is worth far more than either
half on its own.
The one idea everything here is built on:
A matrix IS a function. Its columns are where the basis vectors land.
"""
from __future__ import annotations
import math
Point = tuple[float, float]
Matrix = list[list[float]]
class SingularMatrix(ValueError):
"""Raised when a matrix has no inverse.
Written for you. It subclasses ValueError to match NumPy, whose
`numpy.linalg.LinAlgError` is itself a ValueError -- so `except ValueError`
catches your version and NumPy's alike.
"""
# -- Exercise 1.1 -------------------------------------------------------------
def from_landings(e1_lands_at: Point, e2_lands_at: Point) -> Matrix:
"""Build the 2 by 2 matrix that sends (1, 0) and (0, 1) to these two points.
A matrix is written as a list of ROWS, but it MEANS its columns. So if
(1, 0) lands at (3, 1) and (0, 1) lands at (-1, 2), the matrix is
[[3, -1],
[1, 2]]
-- the first landing place read downwards in the left-hand column, the
second read downwards in the right-hand column.
>>> from_landings((3.0, 1.0), (-1.0, 2.0))
[[3.0, -1.0], [1.0, 2.0]]
"""
raise NotImplementedError("exercise 1.1: from_landings")
# -- Exercise 1.2 -------------------------------------------------------------
def columns_of(matrix: Matrix) -> tuple[Point, Point]:
"""Return the two columns as points: (where e1 lands, where e2 lands).
The exact inverse of exercise 1.1. For [[a, b], [c, d]] the answer is
((a, c), (b, d)).
>>> columns_of([[3.0, -1.0], [1.0, 2.0]])
((3.0, 1.0), (-1.0, 2.0))
"""
raise NotImplementedError("exercise 1.2: columns_of")
# -- Exercise 1.3 -------------------------------------------------------------
def apply(matrix: Matrix, point: Point) -> Point:
"""Send one point through the transformation.
Write it the way the idea is stated, not the way a textbook writes it: the
answer is x lots of the first column, plus y lots of the second.
apply(M, (x, y)) = x * (where e1 landed) + y * (where e2 landed)
That is the same arithmetic as the row-by-row rule and it says out loud why
the columns are the landing places. Hint: `columns_of` is already written
by the time you get here.
>>> apply([[3.0, -1.0], [1.0, 2.0]], (2.0, 1.0))
(5.0, 4.0)
"""
raise NotImplementedError("exercise 1.3: apply")
# -- Exercise 1.4 -------------------------------------------------------------
def scaling(sx: float, sy: float) -> Matrix:
"""Stretch by sx across and sy up.
Derive it, do not look it up. Where does (1, 0) -- one step right -- go
when the plane is stretched sx times horizontally? Where does (0, 1) go?
Write the two answers down as columns with `from_landings`.
>>> scaling(2.0, 3.0)
[[2.0, 0.0], [0.0, 3.0]]
"""
raise NotImplementedError("exercise 1.4: scaling")
# -- Exercise 1.5 -------------------------------------------------------------
def reflection_in_x_axis() -> Matrix:
"""Mirror the plane in the horizontal axis: up becomes down.
Derive it. (1, 0) lies ON the mirror line, so ask yourself whether it can
move at all. (0, 1) is one step up -- where is its reflection?
>>> reflection_in_x_axis()
[[1.0, 0.0], [0.0, -1.0]]
"""
raise NotImplementedError("exercise 1.5: reflection_in_x_axis")
# -- Exercise 1.6 -------------------------------------------------------------
def shear_x(k: float) -> Matrix:
"""Push the plane sideways by k times its height.
A shear slides each point sideways in proportion to how high it is -- the
deck of cards pushed over, where the bottom card does not move and the top
one moves furthest.
Derive it. (1, 0) has height 0, so how far is it pushed? (0, 1) has height
1, so how far is it pushed, and where does it end up?
>>> shear_x(2.0)
[[1.0, 2.0], [0.0, 1.0]]
"""
raise NotImplementedError("exercise 1.6: shear_x")
# -- Exercise 1.7 -------------------------------------------------------------
def rotation(theta: float) -> Matrix:
"""Turn the plane anticlockwise by theta RADIANS about the origin.
The derivation, which needs the unit circle and nothing else:
* draw a circle of radius 1 about the origin;
* start at (1, 0) and walk anticlockwise around the rim until you have
turned through theta;
* the coordinates of where you now stand are, BY DEFINITION,
(cos(theta), sin(theta)). That is what cosine and sine ARE.
So (1, 0) lands at (cos(theta), sin(theta)).
For (0, 1): it is (1, 0) already turned a quarter turn, so after turning by
theta it sits a quarter turn ahead of the first landing place. A quarter
turn anticlockwise sends any (x, y) to (-y, x). Apply that to
(cos(theta), sin(theta)) and you have the second column.
Radians: an angle measured as distance walked around the rim of that unit
circle. A full turn is 2 * pi, so a quarter turn is pi / 2. Use
`math.cos`, `math.sin`, and `math.radians` if you would rather think in
degrees.
>>> [round(v, 10) for row in rotation(math.pi / 2) for v in row]
[0.0, -1.0, 1.0, 0.0]
"""
raise NotImplementedError("exercise 1.7: rotation")
# -- Exercise 1.8 -------------------------------------------------------------
def compose(second: Matrix, first: Matrix) -> Matrix:
"""Return the single matrix that does `first` and then `second`.
Mind the argument order, and mind that it is not arbitrary. Applying
`first` and then `second` to a vector v is
second @ (first @ v)
so the single matrix that does both is `second @ first`: the step that
happens FIRST is written on the RIGHT, because it is the one standing next
to the vector.
Build it the day's way rather than with the row-by-column rule: column 0 of
the answer is wherever (1, 0) ends up after BOTH steps, and column 1 is
wherever (0, 1) ends up. Two calls to `apply` and one to `from_landings`.
>>> compose([[0.0, -1.0], [1.0, 0.0]], [[1.0, 2.0], [0.0, 1.0]])
[[0.0, -1.0], [1.0, 2.0]]
"""
raise NotImplementedError("exercise 1.8: compose")
# -- Exercise 1.9 -------------------------------------------------------------
def determinant(matrix: Matrix) -> float:
"""The factor by which this transformation multiplies area, sign included.
For [[a, b], [c, d]] it is a*d - b*c. Compute exactly that -- one multiply,
one multiply, one subtract -- so that whole-number inputs give an exact
whole-number answer.
What the number means, which matters more than the formula:
* its size is the area factor: a unit square of area 1 comes out with
area |determinant|;
* negative means the plane was flipped over;
* zero means the plane was flattened onto a line, and nothing can undo it.
>>> determinant([[3.0, -1.0], [1.0, 2.0]])
7.0
>>> determinant([[1.0, 2.0], [2.0, 4.0]])
0.0
"""
raise NotImplementedError("exercise 1.9: determinant")
# -- Exercise 1.10 ------------------------------------------------------------
def inverse(matrix: Matrix) -> Matrix:
"""The transformation that undoes this one.
For 2 by 2, the inverse of [[a, b], [c, d]] is
1 / determinant * [[d, -b], [-c, a]]
and it exists exactly when the determinant is not zero -- which is the same
sentence as "exactly when no area was destroyed". If two starting points
landed on the same place, no rule could send that place back to both, so
there is nothing to return.
So: compute the determinant first. If it is zero, `raise SingularMatrix`
with a message that says why. Otherwise divide each of the four rearranged
entries by it.
>>> inverse([[1.0, 2.0], [0.0, 1.0]])
[[1.0, -2.0], [0.0, 1.0]]
"""
raise NotImplementedError("exercise 1.10: inverse")
# =============================================================================
# Written for you below this line -- read them, they are used by the tests.
# =============================================================================
def identity() -> Matrix:
"""The do-nothing transformation."""
return [[1.0, 0.0], [0.0, 1.0]]
def transform_polygon(matrix: Matrix, polygon: list[Point]) -> list[Point]:
"""Send every corner of a polygon through the transformation.
Corners are enough, because a linear transformation sends straight lines to
straight lines -- which is exactly what makes it cheap, and exactly what
stops any stack of them ever drawing a curve.
"""
return [apply(matrix, point) for point in polygon]
def signed_area(polygon: list[Point]) -> float:
"""The area of a polygon, negative if its corners run clockwise.
The shoelace formula, given to you because measuring polygons is not what
today is about: walk the corners in order and for each edge add
x_here * y_next - x_next * y_here, then halve the total.
The SIGN is the part this lab uses. Corners listed anticlockwise give a
positive area; a transformation with a negative determinant turns the shape
over and the same corners now run clockwise, so the answer comes out
negative. That is "the plane was flipped" measured rather than asserted.
"""
total = 0.0
count = len(polygon)
for i in range(count):
x_here, y_here = polygon[i]
x_next, y_next = polygon[(i + 1) % count]
total += x_here * y_next - x_next * y_here
return total / 2.0
def rank(matrix: Matrix, tol: float = 1e-12) -> int:
"""How many dimensions survive: 2 fills the plane, 1 a line, 0 the origin."""
if abs(determinant(matrix)) > tol:
return 2
if any(abs(entry) > tol for row in matrix for entry in row):
return 1
return 0
tests/run_tests.sh (20440 bytes)
#!/usr/bin/env bash
# Tests for the Day 102 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# values, never by reading source:
#
# * a matrix's COLUMNS are where the basis vectors land, and the two landings
# alone determine where every other vector goes;
# * scaling, reflection, shear and rotation each come out of that one
# question, and each agrees with NumPy;
# * a matrix preserves addition and scalar multiplication, and "matrix plus a
# constant" fails BOTH -- by exactly the constant, and by exactly (s - 1)
# times the constant, which is asserted rather than described;
# * a quarter turn's cosine is 6.123233995736766e-17 and not 0.0, so every
# float check here states a tolerance and the harness asserts the
# inexactness itself;
# * composing two transformations is one matrix product, BA means A first,
# and AB is a different transformation;
# * the determinant IS the signed area of the transformed unit square --
# positive, negative and zero cases all measured;
# * a singular matrix's inverse raises numpy.linalg.LinAlgError with the
# message "Singular matrix", and the exact class is asserted;
# * twenty stacked linear layers collapse to one 2 by 2 matrix;
# * nothing is left behind on disk.
#
# 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 102 — Where Do the Basis Vectors Land?"
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" "2" "${major}"
# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------
for script in 01_columns_are_landings 02_building_the_transformations \
03_linear_or_not 04_composition_and_order \
05_determinant_inverse_rank 06_the_limit_of_linear; 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 exceptions"
# --------------------------------------------------------------------------
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 75 ]; then
check "the reference suite ran at least 75 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 75 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 modules called `transforms` and
# `shapes`, 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 random
import numpy as np
import shapes
from transforms import (
apply,
columns_of,
compose,
determinant,
inverse,
is_linear,
preserves_addition,
preserves_scaling,
rank,
reflection_in_x_axis,
rotation,
scaling,
shear_x,
signed_area,
transform_polygon,
SingularMatrix,
)
TOL = shapes.TOL
M = shapes.PICTURE_MATRIX
print("columns", columns_of(M))
print("row0_is_not_a_landing", tuple(M[0]) != columns_of(M)[0])
print("sends_2_1", apply(M, (2.0, 1.0)))
print("det_picture_scratch", determinant(M))
print("det_picture_numpy_exact", float(np.linalg.det(np.array(M))) == 7.0)
print("det_picture_numpy_close", abs(float(np.linalg.det(np.array(M))) - 7.0) < 1e-14)
print("scale", scaling(2.0, 3.0))
print("flip", reflection_in_x_axis())
print("shear", shear_x(2.0))
print("shear_on_axis", apply(shear_x(2.0), (5.0, 0.0)))
Q = rotation(math.pi / 2)
print("cos_quarter_exact_zero", math.cos(math.pi / 2) == 0.0)
print("quarter_within_tol", all(
abs(a - b) <= TOL for a, b in zip(apply(Q, shapes.E1), (0.0, 1.0))
))
print("sin_thirty_exact_half", math.sin(math.radians(30)) == 0.5)
S = shapes.SCALE_MATRIX
b = (1.0, 1.0)
u, v, s = (1.0, 2.0), (3.0, -1.0), 5.0
linear = lambda p: apply(S, p)
affine = lambda p: tuple(c + o for c, o in zip(apply(S, p), b))
print("linear_is_linear", is_linear(linear, u, v, s, TOL))
print("affine_is_linear", is_linear(affine, u, v, s, TOL))
ok, together, separately = preserves_addition(affine, u, v, TOL)
print("affine_add_gap", tuple(round(x - y, 12) for x, y in zip(separately, together)))
ok, first, after = preserves_scaling(affine, u, s, TOL)
print("affine_scale_gap", tuple(round(x - y, 12) for x, y in zip(after, first)))
print("linear_fixes_origin", linear((0.0, 0.0)) == (0.0, 0.0))
print("affine_moves_origin", affine((0.0, 0.0)) == b)
A, B = shear_x(2.0), Q
BA, AB = compose(B, A), compose(A, B)
print("BA_matches_hand", all(
abs(x - y) <= TOL
for r1, r2 in zip(BA, shapes.SHEAR_THEN_ROTATE) for x, y in zip(r1, r2)
))
print("AB_matches_hand", all(
abs(x - y) <= TOL
for r1, r2 in zip(AB, shapes.ROTATE_THEN_SHEAR) for x, y in zip(r1, r2)
))
print("orders_differ", any(
abs(x - y) > TOL for r1, r2 in zip(BA, AB) for x, y in zip(r1, r2)
))
two_steps = transform_polygon(B, transform_polygon(A, shapes.FLAG))
at_once = transform_polygon(BA, shapes.FLAG)
print("one_matrix_equals_two_steps", all(
abs(x - y) <= TOL for p, q in zip(two_steps, at_once) for x, y in zip(p, q)
))
for name, matrix, expected in (
("area_scale", scaling(2.0, 3.0), 6.0),
("area_shear", shear_x(2.0), 1.0),
("area_flip", reflection_in_x_axis(), -1.0),
("area_collapse", shapes.COLLAPSE_MATRIX, 0.0),
):
area = signed_area(transform_polygon(matrix, shapes.UNIT_SQUARE))
print(name, round(area, 12), round(determinant(matrix), 12), expected)
G = shapes.COLLAPSE_MATRIX
print("collapse_on_one_line", all(
abs(apply(G, p)[1] - 2.0 * apply(G, p)[0]) <= TOL
for p in [(1.0, 0.0), (0.0, 1.0), (3.0, -1.0)]
))
print("collapse_two_points_one_landing", apply(G, (2.0, 0.0)) == apply(G, (0.0, 1.0)))
print("rank_collapse", rank(G), int(np.linalg.matrix_rank(np.array(G))))
print("rank_zero_matrix", rank([[0.0, 0.0], [0.0, 0.0]]))
print("inverse_of_shear", inverse(shear_x(2.0)) == shear_x(-2.0))
try:
inverse(G)
except SingularMatrix:
print("scratch_inverse_refuses", "SingularMatrix")
else:
print("scratch_inverse_refuses", "NOTHING_RAISED")
try:
np.linalg.inv(np.array(G))
except Exception as exc: # deliberately broad: the TYPE is what is asserted
print("numpy_inverse_refuses", type(exc).__name__, str(exc))
else:
print("numpy_inverse_refuses", "NOTHING_RAISED", "")
print("linalgerror_is_valueerror", issubclass(np.linalg.LinAlgError, ValueError))
random.seed(102)
stack = [
[[random.uniform(-2, 2) for _ in range(2)] for _ in range(2)] for _ in range(20)
]
point = (0.7, -0.4)
stepwise = point
for layer in stack:
stepwise = apply(layer, stepwise)
combined = stack[0]
for layer in stack[1:]:
combined = compose(layer, combined)
at_once_pt = apply(combined, point)
print("stack_collapses", all(
abs(a - c) <= 1e-9 * max(1.0, abs(a)) for a, c in zip(stepwise, at_once_pt)
))
print("stack_still_fixes_origin", all(
abs(c) <= TOL for c in apply(combined, (0.0, 0.0))
))
PY
)"
get() { printf '%s\n' "${facts}" | grep "^$1 " | cut -d' ' -f2-; }
check_eq "the columns are the two landing places" \
"((3.0, 1.0), (-1.0, 2.0))" "$(get columns)"
check_eq "row 0 is NOT a landing place" "True" "$(get row0_is_not_a_landing)"
check_eq "the matrix sends (2, 1) to the hand-worked (5, 4)" \
"(5.0, 4.0)" "$(get sends_2_1)"
check_eq "the from-scratch determinant of the picture matrix is exactly 7" \
"7.0" "$(get det_picture_scratch)"
check_eq "numpy.linalg.det is NOT exactly 7 on the same matrix" \
"False" "$(get det_picture_numpy_exact)"
check_eq "numpy.linalg.det is within 1e-14 of 7" \
"True" "$(get det_picture_numpy_close)"
check_eq "scaling(2, 3) is derived correctly" \
"[[2.0, 0.0], [0.0, 3.0]]" "$(get scale)"
check_eq "reflection in the x axis is derived correctly" \
"[[1.0, 0.0], [0.0, -1.0]]" "$(get flip)"
check_eq "shear_x(2) is derived correctly" \
"[[1.0, 2.0], [0.0, 1.0]]" "$(get shear)"
check_eq "a shear leaves a point at height 0 exactly where it was" \
"(5.0, 0.0)" "$(get shear_on_axis)"
# Section 6 re-runs this script with D102_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_cos_exact="False"
if [ -n "${D102_SELF_TEST:-}" ]; then
expected_cos_exact="True" # the naive belief, deliberately wrong here
fi
check_eq "cos(pi / 2) is not exactly 0.0, which is why a tolerance is required" \
"${expected_cos_exact}" "$(get cos_quarter_exact_zero)"
check_eq "the quarter turn still lands on (0, 1) within the stated tolerance" \
"True" "$(get quarter_within_tol)"
check_eq "sin(30 degrees) is not exactly 0.5 either" \
"False" "$(get sin_thirty_exact_half)"
check_eq "a matrix is linear on the tested pair" "True" "$(get linear_is_linear)"
check_eq "matrix-plus-a-constant is not linear" "False" "$(get affine_is_linear)"
check_eq "the addition failure is exactly b" "(1.0, 1.0)" "$(get affine_add_gap)"
check_eq "the scaling failure is exactly (s - 1) times b" \
"(4.0, 4.0)" "$(get affine_scale_gap)"
check_eq "a linear map fixes the origin" "True" "$(get linear_fixes_origin)"
check_eq "an affine map moves it" "True" "$(get affine_moves_origin)"
check_eq "compose(B, A) matches the hand-worked shear-then-rotate" \
"True" "$(get BA_matches_hand)"
check_eq "compose(A, B) matches the hand-worked rotate-then-shear" \
"True" "$(get AB_matches_hand)"
check_eq "the two orders are different transformations" "True" "$(get orders_differ)"
check_eq "one composed matrix reproduces the two-step sequence on every corner" \
"True" "$(get one_matrix_equals_two_steps)"
check_eq "scaling(2, 3) multiplies the unit square's area by 6" \
"6.0 6.0 6.0" "$(get area_scale)"
check_eq "a shear preserves area exactly" "1.0 1.0 1.0" "$(get area_shear)"
check_eq "a reflection gives a NEGATIVE area of the same size" \
"-1.0 -1.0 -1.0" "$(get area_flip)"
check_eq "a collapse gives area 0" "0.0 0.0 0.0" "$(get area_collapse)"
check_eq "the collapse puts every vector on the line y = 2x" \
"True" "$(get collapse_on_one_line)"
check_eq "two different points land on the same place, so nothing can undo it" \
"True" "$(get collapse_two_points_one_landing)"
check_eq "the collapse has rank 1, and numpy agrees" "1 1" "$(get rank_collapse)"
check_eq "the all-zero matrix has rank 0" "0" "$(get rank_zero_matrix)"
check_eq "the inverse of shear_x(2) is shear_x(-2)" "True" "$(get inverse_of_shear)"
check_eq "the from-scratch inverse refuses a singular matrix" \
"SingularMatrix" "$(get scratch_inverse_refuses)"
check_eq "numpy.linalg.inv raises LinAlgError with the message 'Singular matrix'" \
"LinAlgError Singular matrix" "$(get numpy_inverse_refuses)"
check_eq "numpy.linalg.LinAlgError is catchable as a ValueError" \
"True" "$(get linalgerror_is_valueerror)"
check_eq "twenty stacked linear layers collapse to one 2 by 2 matrix" \
"True" "$(get stack_collapses)"
check_eq "and the stack still cannot move the origin" \
"True" "$(get stack_still_fixes_origin)"
# --------------------------------------------------------------------------
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 naive belief that cos(pi / 2) is exactly 0.0, and asserts that the
# re-run reports the failure and exits non-zero. If this section passes,
# section 5 is not decorative.
if [ -z "${D102_SELF_TEST:-}" ]; then
self_out="$(D102_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: cos(pi / 2) is not exactly 0.0"*)
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 102 lab
Every failure listed here was produced on the authoring machine while building this lab, unless the entry says otherwise. Where a message is quoted, it is a quote.
ModuleNotFoundError: No module named 'numpy'
The lab-local environment has not been created, or you are running the system
python3 instead of the one inside it.
cd labs/sections/math-statistics-and-data/day-102-linear-transformations
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. Note the .venv/bin/ prefix on every command in this lab; that
is what selects the right interpreter.
ModuleNotFoundError: No module named 'transforms' (or 'shapes')
You ran a reference script from the lab directory rather than from inside
examples/. The scripts import transforms.py and shapes.py from beside
themselves, so:
cd examples
../.venv/bin/python3 01_columns_are_landings.py
cd ..
The pytest commands are the other way round — run those from the lab directory, because they name the directory to collect:
.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
Unattempted exercises show as s rather than .
That is correct. s is a skip, and it means "not attempted". The starter suite
skips anything that still raises NotImplementedError or whose prediction in
answers.py is still None, so your score only ever counts work you actually
did. An untouched checkout prints 1 passed, 53 skipped.
A test failed with NotImplementedError instead of skipping
This happened while building the lab, and it is worth understanding rather than just fixing. Python evaluates arguments before the call, so a test written as
written(compose, rotation(math.pi / 2), shear_x(2.0))
calls rotation and shear_x first — and if either is still unwritten, the
NotImplementedError escapes before written ever runs, and pytest reports a
failure. It would say "attempted and wrong" about work you had not attempted,
which is exactly the lie the skip mechanism exists to prevent. The fix, which
is now in the suite, is to pass a callable that does the whole thing:
written(lambda: compose(rotation(math.pi / 2), shear_x(2.0)))
If you add tests of your own, follow that shape.
The starter tests pass without me writing anything
Almost certainly you ran a bare pytest with no directory argument, and the
import guard is missing or has been edited. Both examples/ and starter/
contain modules called transforms and shapes, and pytest puts a test file's
directory on sys.path to import it — so collecting both suites at once can
import the reference transforms and hand it to the starter tests, which then
pass against a solution you did not write.
Each directory's conftest.py prevents this by putting its own directory first
on the path and dropping any transforms, shapes or answers module that
was imported from elsewhere. Section 4 of tests/run_tests.sh proves the guard
still works by running both suites together and asserting the skip count did
not change.
If you have deleted a conftest.py, restore it with
git checkout -- starter/conftest.py.
cos(pi / 2) prints 6.123233995736766e-17 — is that broken?
No, and one of the reference tests asserts that it happens. pi cannot be
represented exactly in binary floating point, so the value handed to cos is
not quite pi/2, and its cosine is not quite zero. sin(30 degrees) is likewise
0.49999999999999994.
This is why the lab compares floats with a stated tolerance of 1e-12 and
never with ==. If you write your own check as assert value == 0.0, it will
fail on a correct answer. See expected-output/FIELDS.md for the full note.
numpy.linalg.det gives 7.000000000000001 where I expect 7
Also expected, also asserted. Section 8 of 05_determinant_inverse_rank.py
explains it: the from-scratch determinant computes a*d - b*c directly,
which is exact on whole numbers, while numpy.linalg.det factorises the matrix
first — the general method that also works on a 500 by 500 matrix — and that
factorisation rounds. Compare determinants with a tolerance.
numpy.linalg.LinAlgError: Singular matrix
The intended behaviour of exercise 6.8, not a fault. numpy.linalg.inv raises
it for [[1, 2], [2, 4]], whose determinant is 0. The lab's own inverse
raises SingularMatrix for the same matrix with a longer message. Both are
catchable as ValueError, because numpy.linalg.LinAlgError is a subclass of
it:
except ValueError: # catches both
...
AssertionError in my rotation with the two off-diagonal signs swapped
The commonest wrong answer, and it is a clockwise rotation rather than an
anticlockwise one. The check: rotation(pi / 2) must send (1, 0) to
(0, 1) — up — not to (0, -1). If yours goes down, swap which entry
carries the minus sign. The minus belongs on the -sin(theta) in the top
right, which is the first coordinate of where (0, 1) lands.
My compose gives the right matrix for the wrong reason
Check it against a case where the order matters, not against two rotations
(which commute and will agree either way). shear_x(2) and rotation(pi / 2)
are the pair the tests use precisely because B @ A and A @ B differ:
shear then rotate: [[0, -1], [1, 2]]
rotate then shear: [[2, -1], [1, 0]]
bash: tests/run_tests.sh: No such file or directory
Run it from the lab directory, not from the repository root or from tests/:
cd labs/sections/math-statistics-and-data/day-102-linear-transformations
bash tests/run_tests.sh
FAIL: pytest not found.
The harness looks for pytest in three places, in order: the PYTEST
environment variable, .venv/bin/pytest inside the lab, and your PATH. It
stops with instructions rather than skipping checks quietly. Either create the
environment as above, or point it at an existing one:
PYTEST=/path/to/pytest bash tests/run_tests.sh
__pycache__ directories appearing
The lab's own commands set PYTHONDONTWRITEBYTECODE=1 and pass
-p no:cacheprovider, so they leave nothing behind, and section 7 of the
harness fails if anything does. If you have run a script another way, clean up
with:
find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +
rm -rf .pytest_cache
Windows
Not run here, and this file will not pretend otherwise. Use the Windows
Subsystem for Linux and follow the Linux instructions unchanged, or use Git
Bash with .venv\Scripts\python.exe and .venv\Scripts\pytest.exe in place of
the .venv/bin/ paths. The Python and the arithmetic are identical; only the
path separators and the environment layout differ.
Security notes
Security notes — Day 102 lab
What this lab does
It computes and it prints. Six reference scripts, two test suites and a bash harness, all working on matrices with four entries each. There is no server, no client, no database, no file written outside this directory, and no data that belongs to anybody.
What it does not do
| Concern | Status here |
|---|---|
| Network access | Only the one-time pip install. No lab source opens a socket, reads a URL or contacts a service, and section 7 of tests/run_tests.sh greps examples/ and starter/ for the patterns that would show otherwise. |
| Credentials | None. No API key, no token, no password, no account. requires_api_key: false in metadata.yml. |
| Elevated privileges | None. Nothing in this lab needs sudo, and you should not give it any. |
| Files written | The virtual environment in .venv/, and nothing else. The scripts write no output files; the captured text in expected-output/ was redirected there by hand when the lab was built. |
| Personal data | None. Every number is invented: a made-up matrix, a unit square, an L-shaped flag, and one pseudo-random stack seeded with 102 so it is identical on every machine. |
| Code execution from data | None. Nothing is eval-ed, nothing is deserialised, no file is read as code. |
Deleting everything
rm -rf .venv
find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +
rm -rf .pytest_cache
git checkout -- starter/
That is a complete undo. The lab leaves no trace elsewhere on your machine.
Installing packages, briefly
The single network operation is pip install -r requirements/requirements.txt,
which fetches numpy and pytest from the Python Package Index. Two habits worth
keeping, and they are general rather than specific to this lab:
- Install into a virtual environment, not the system Python. Everything in
this lab does. A lab-local
.venv/cannot break anything else you have, andrm -rf .venvundoes it completely. - Pin versions and read the file before running it.
requirements.txthere is two lines with exact versions, and section 1 of the harness verifies that what is installed matches what is written. An unpinned requirement is a request to install whatever exists at the moment you run it.
The one thing worth carrying away
This lab has no security surface of its own, so the transferable point is about the mathematics rather than the code, and it is this: a transformation with determinant zero destroys information, and no amount of downstream processing recovers it.
That sounds abstract until you notice how often it is the property you actually want, and how often it is the property that bites.
When you want it: a hash, a redaction, a one-way projection. If two different inputs must be indistinguishable afterwards, you need a step from which they cannot be told apart — and a rank-deficient transformation is one honest way to say that in linear terms.
When it bites: a pipeline that reduces dimensions somewhere in the middle
cannot be inverted after that point, so "we can always reconstruct the original
from the embedding" is a claim to check rather than assume. Sometimes it is
false in the reassuring direction — the reduction really did destroy the
identifying detail. Sometimes it is false in the alarming direction: the
transformation had full rank after all, the reduction was lossless, and what
looked like anonymisation was a reversible relabelling. Section 5 of
05_determinant_inverse_rank.py shows both cases in two lines each: compute
the determinant, and count the dimensions that survive.
The lab's rank function and numpy.linalg.matrix_rank answer that question
directly, and they are worth reaching for before anyone claims a step is
irreversible.