Skip to content
  • Home
  • How To
  • M0
  • M1
  • Agents
  • Module 2 - Data
  • M3
  • M4
  • M5
  • M6
  • Progress
  • Contact
  • Pricing
  • Sign in
  • Future
  1. Module 2M2
  2. ›
    S2NumPy, The Array
  3. ›
    Lesson 4 of 5Broadcasting
  4. ›
    S2›Lesson 4 of 5
  1. Module 2
  2. ›
  3. Section 2
  4. ›
  5. Lesson 4

Broadcasting

FREE·~1.5 h·2 labs·4 questions·1 assignment


Context

Broadcasting is the rule NumPy applies when an element-wise operation meets two arrays of different shapes. It lines the shapes up from the right and, wherever one array has a dimension of size 1 or none at all, reuses that array's values along the other array's length, so a short vector meets a whole table in one expression.

Most per-feature work on a table is written this way: subtracting a reference value from every listing, scaling each feature into one range before a model sees it, and, in Section 2.3, adding a model's bias term (a fixed amount the model builds into every price it predicts) to its predictions. Per-listing work is written this way too, such as comparing each of a listing's price estimates with the price that listing sold for.

The rule pairs sizes by position, never by meaning. The loud failure is a ValueError, raised when one value per listing meets a table with a different number of features, two or more. The quiet ones return plausible answers: a square table applies a per-listing vector per feature instead, and a column of predictions minus a vector of prices returns n times n differences where n residuals (each prediction minus its own listing's price) were wanted. The previous lesson paired arrays of one shape, or an array with one number, and this lesson pairs everything else.


Goal

  • Predict the shape an element-wise operation returns from its two operand shapes, or that it raises, before running it.
  • Apply a per-feature (k,) vector and a per-listing (n, 1) column to an (n, k) table, and state which direction each one acts in.
  • Build a column or a row from a 1-D array with np.newaxis, and a column with reshape(-1, 1).
  • Catch the silent (n, n) result of an (n, 1) column meeting an (n,) vector by checking .shape.
  • Min-max scale every feature of a table in one expression from given per-feature ranges.

Prerequisites

None of this is required, the lesson stands on its own. Both are here if you want the rule drawn out once before you meet it.

  • YouTube - "Broadcasting in Python (C1W2L15)" by DeepLearningAI. Andrew Ng divides a small table of food calories by its column totals, then states the rule for an m by n array against a 1 by n row and an m by 1 column.
  • Official docs - NumPy User Guide, "Broadcasting", the "General broadcasting rules" section. The rule as NumPy states it, with pairs of shapes lined up on the right.

Table of Contents

  • The Broadcasting Rule
  • Row-wise and Column-wise Operations
  • Adding an Axis - np.newaxis and reshape
  • Scaling Features by Column

Theory

The Broadcasting Rule

What. Broadcasting is the rule that decides whether two arrays of different shapes can meet in an element-wise operation, and what shape the result takes. Why. Per-feature and per-listing corrections are everyday table work, and without the rule each would need the small array written out at full size, or a Python loop. With it, one expression runs in Lesson 2.2.3's compiled loop. When. Whenever the two operands of an element-wise operation differ in shape, every array-and-number line of Lesson 2.2.3 included. The operator applies the rule itself.

The rule reads two shapes, Lesson 2.2.1's tuples of sizes, in four steps:

  1. Write the two shapes one above the other, aligned on the right.
  2. Where one shape is shorter, count each missing leading dimension as size 1.
  3. Two sizes in the same place pair when they are equal or when one of them is 1, and a single place that does not pair raises a ValueError.
  4. In each place the result takes the size that is not 1, or the shared size when the two are equal, and an array with a 1 there has its values reused along that length.

The first example corrects every listing's estimates by a known offset per source:

import numpy as np

# Four listings, three estimates each, in thousands:
# agent's appraisal, tax assessment, online estimate.
est = np.array([[310, 280, 325],
                [245, 231, 250],
                [420, 365, 442],
                [199, 186, 209]])
# How far each source usually lands above the sale price.
offset = np.array([10, -20, 15])

print(est)
print(offset)
print(est.shape, offset.shape)
print(est - offset)
[[310 280 325]
 [245 231 250]
 [420 365 442]
 [199 186 209]]
[ 10 -20  15]
(4, 3) (3,)
[[300 300 310]
 [235 251 235]
 [410 385 427]
 [189 206 194]]

Aligned on the right, the two shapes read like this:

first dimension last dimension
est 4 3
offset missing, counts as 1 3
result 4 3

3 pairs with the equal 3, and 4 pairs with the missing size, so offset's one row was reused for all four listings: every appraisal lost 10, every tax assessment gained 20 and every online estimate lost 15.

A plain number is the simplest case. Its shape is (), every dimension missing, so it pairs with any shape, which is how Lesson 2.2.3 combined an array with one number. Predict a shape before running a line, and check the prediction with arrays of ones, since only shapes matter:

import numpy as np

table = np.ones((4, 3))
row = np.ones(3)
col = np.ones((4, 1))
# Two batches of the same four listings.
batches = np.ones((2, 4, 3))

print(np.shape(7))                        # ()
print((table + 7).shape)                  # (4, 3)
print((table + row).shape)                # (4, 3)
print((table + row.reshape(1, 3)).shape)  # (4, 3)
print((table + col).shape)                # (4, 3)
print((col + row).shape)                  # (4, 3)
print((batches + col).shape)              # (2, 4, 3)

In col + row both operands are reused, the column across three places and the row down four. The last line is the same four steps with one more place, which Section 2.3 uses to compare every listing with every other one.

Now one value per listing. price holds what each listing sold for, and est - price looks as if it pairs four listings with four prices. Saved as pairing.py:

import numpy as np

est = np.array([[310, 280, 325],
                [245, 231, 250],
                [420, 365, 442],
                [199, 186, 209]])
price = np.array([305, 248, 410, 200])  # what each listing sold for

err = est - price
Traceback (most recent call last):
  File "/home/goku/dev/bootcamp/pairing.py", line 9, in <module>
    err = est - price
          ~~~~^~~~~~~
ValueError: operands could not be broadcast together with shapes (4,3) (4,) 

price's only size, 4, sits under est's last size, 3, and the two are neither equal nor 1. The 4 at the front of est cannot rescue it, because every place must pair. This is Lesson 2.2.3's ValueError with its cause in view: the message names both shapes in operand order, with no space after the comma, and never a variable.

A column shape puts the prices' 4 under est's 4 and a 1 under the 3. Replace the last line of pairing.py with these three and run it again:

price_col = price.reshape(4, 1)
print(price_col)
print(price_col.shape, (est - price_col).shape)
[[305]
 [248]
 [410]
 [200]]
(4, 1) (4, 3)

4 pairs with 4 and the 1 pairs with 3, so each listing's price is reused across that listing's three estimates. What the twelve differences mean is the next subtitle's subject.

DON'T:

  • DON'T line shapes up from the left. (4, 3) and (4,) both start with 4, and the pairing still fails, because the rule compares the vector's 4 with the table's 3.
  • DON'T look for variable names in the error. It names the two shapes in operand order, so print the .shape of each operand on the failing line.
  • DON'T assume only the smaller array is reused. A (4, 1) column and a (3,) row are both reused, into a (4, 3) result that neither of them held.

Row-wise and Column-wise Operations

What. On an (n, k) table of listings by features, a (k,) vector applies one value per column, the same to every listing, and an (n, 1) column applies one value per row, the same to every feature of that listing. Why. The direction carries the meaning. An offset belongs to a source's column and a sale price belongs to a listing's row, and since the rule pairs sizes by position, only the shape you give a vector says which one you meant. When. Every time a 1-D vector meets a table, decide whether its values belong to features or to listings before writing the line, then give it the shape that says so.

est - offset was the column-wise case, one value per source applied down every row. The row-wise case gives the sale prices the (4, 1) shape, so every estimate meets the price its own listing sold for:

import numpy as np

est = np.array([[310, 280, 325],
                [245, 231, 250],
                [420, 365, 442],
                [199, 186, 209]])
price = np.array([305, 248, 410, 200])

# Each estimate minus its own listing's sale price.
err = est - price.reshape(4, 1)
print(err)
[[  5 -25  20]
 [ -3 -17   2]
 [ 10 -45  32]
 [ -1 -14   9]]

A row is one listing: the third listing's online estimate came in 32 above its sale price and its tax assessment 45 below. A column is one source: the tax column is negative in all four rows and the online column positive in all four.

A (k,) vector works per column for one reason only, k matches the table's last size. A per-listing vector left as (n,) therefore fails when n differs from k, as pairing.py did, unless k is 1, the column case of the next subtitle, and when n equals k the table is square, the pairing succeeds against the columns, and nothing reports the wrong direction:

import numpy as np

# Three listings, so the table is square, (3, 3).
est3 = np.array([[310, 280, 325],
                 [245, 231, 250],
                 [420, 365, 442]])
price3 = np.array([305, 248, 410])

print(est3 - price3)
print(est3 - price3.reshape(3, 1))
[[   5   32  -85]
 [ -60  -17 -160]
 [ 115  117   32]]
[[  5 -25  20]
 [ -3 -17   2]
 [ 10 -45  32]]

The first result subtracted 305 from every agent appraisal, 248 from every tax assessment and 410 from every online estimate. Both results are (3, 3), and their diagonals agree, 5, -17 and 32, because the value in row i and column i meets price i under either reading, so a spot check of the first value passes on the wrong answer.

DON'T:

  • DON'T give a per-listing vector to a table as (n,). It raises when n differs from a feature count above 1, returns the (n, n) table of the next subtitle against a single feature, and on a square table silently works per feature.
  • DON'T spot-check a square result on its diagonal, the first value included. Both readings agree there, so the check passes on the wrong answer.

Adding an Axis - np.newaxis and reshape

What. np.newaxis, placed inside an index, adds a new axis, a dimension of size 1, at that position: v[:, np.newaxis] turns an (n,) vector into an (n, 1) column and v[np.newaxis, :] into a (1, n) row. v.reshape(-1, 1) builds the same column, the -1 letting NumPy work out n as in Lesson 2.2.1. Why. A size-1 dimension is what broadcasting reuses, so adding one in the right place chooses the direction a vector acts in. Spotting one you did not intend matters as much, because an (n, 1) column meeting an (n,) vector is the silent failure that costs the most. When. Add a trailing axis whenever a 1-D vector must act per row, and check the shape of every result you did not predict, above all a difference between predictions and the prices they predict.

import numpy as np

price = np.array([305, 248, 410, 200])

print(price[:, np.newaxis].shape)  # (4, 1)
print(price[np.newaxis, :].shape)  # (1, 4)
print(price.reshape(-1, 1).shape)  # (4, 1)
print(np.newaxis is None)          # True

np.newaxis is NumPy's name for None, so code that writes price[:, None] does the same thing. The colon keeps the existing dimension, and the position of np.newaxis puts the new one after it for a column or before it for a row. reshape(-1, 1) reads as one column and as many rows as it takes, so it never needs the length.

An (n, 1) column against an (n,) vector is the col + row line again, both reused, and the result is (n, n), every element of one combined with every element of the other, with no error. Such a column appears whenever a column is selected with a one-item list, Lesson 2.2.2's fancy indexing in the column place: est[:, [2]] is the online estimates as a (4, 1) column, where est[:, 2] is a (4,) vector. Treat the online estimate as a model's prediction and compute its residuals:

import numpy as np

est = np.array([[310, 280, 325],
                [245, 231, 250],
                [420, 365, 442],
                [199, 186, 209]])
price = np.array([305, 248, 410, 200])

pred = est[:, [2]]  # a one-item list keeps the column axis
resid = pred - price
print(pred.shape, price.shape, resid.shape)
print(resid)
(4, 1) (4,) (4, 4)
[[  20   77  -85  125]
 [ -55    2 -160   50]
 [ 137  194   32  242]
 [ -96  -39 -201    9]]

Sixteen numbers came back where four were wanted. The four real residuals sit on the diagonal, 20, 2, 32 and 9, and each of the other twelve compares one listing's prediction with another listing's price. The mean does not expose it. Add these lines to the same script:

right = est[:, 2] - price          # (4,), one per listing
print(resid.mean(), right.mean())  # 15.75 15.75
print(right)                       # [20  2 32  9]
# The guard: residuals are shaped like the target.
assert right.shape == price.shape

The average of the sixteen equals the true average error, and not by luck: averaging every prediction against every price comes to the mean prediction minus the mean price, which is what the four real residuals average to. The shape cannot look right. The same assert written on resid, Lesson 1.6.3's tool, stops the script with an AssertionError. Selecting with est[:, 2], or flattening the column with Lesson 2.2.1's ravel, gives the four residuals.

DON'T:

  • DON'T subtract an (n,) vector from an (n, 1) column, in either order. NumPy returns the (n, n) table of every pairing and raises nothing.
  • DON'T judge a broadcast result by its mean. The mean of the (4, 4) table above is the true mean error, so the shape, not the mean, shows the mistake.
  • DON'T write price[np.newaxis] when you want a column. With nothing after it the new axis goes first, and the result is a (1, 4) row.

Scaling Features by Column

What. Min-max scaling maps every feature onto the range 0 to 1 with one expression, (X - lo) / (hi - lo), where lo and hi are (k,) arrays holding each feature's smallest and largest valid value. Why. Raw features sit on scales that differ by orders of magnitude, area in the tens to hundreds of square meters and rooms in single figures. Section 2.3's distance between two listings adds one squared difference per feature, so the features whose values spread the widest dominate it, and scaling puts every feature on the same footing first. When. When features on different scales feed a calculation that combines them and each feature's valid range is documented, as when a dataset's specification lists area as 20 to 220 square meters. Ranges computed from the data itself need per-column statistics, Lesson 2.2.5's, where the same idea becomes standardization.

import numpy as np

# Four listings: area_sqm, rooms, age.
X = np.array([[ 85, 3, 12],
              [ 62, 2, 30],
              [120, 4,  5],
              [ 48, 1, 41]])
# Each feature's documented valid range.
lo = np.array([20, 1, 0])
hi = np.array([220, 6, 100])

X_scaled = (X - lo) / (hi - lo)
print(hi - lo)
print(X_scaled)
[200   5 100]
[[0.325 0.4   0.12 ]
 [0.21  0.2   0.3  ]
 [0.5   0.6   0.05 ]
 [0.14  0.    0.41 ]]

Every piece is the column-wise case: X - lo subtracts each feature's minimum down its column, and hi - lo is a (3,) vector of widths that puts each column on its own scale. The last listing's rooms value is exactly 0 because it sits on its feature's minimum.

The same ranges serve one new listing, a (3,) against (3,) ranges, the equal-shapes case. A value outside its documented range lands outside 0 to 1, and nothing warns:

import numpy as np

lo = np.array([20, 1, 0])
hi = np.array([220, 6, 100])
new = np.array([250, 7, 3])       # one listing, (3,)

new_scaled = (new - lo) / (hi - lo)
print(new_scaled)                 # [1.15 1.2  0.03]
print(np.clip(new_scaled, 0, 1))  # [1.   1.   0.03]

250 square meters scales to 1.15 and seven rooms to 1.2. Lesson 2.2.3's np.clip caps both at 1, which suits a range that is a hard limit, and whether to cap, drop or flag such a listing is a call Lesson 2.5.4 makes column by column.

DON'T:

  • DON'T drop the parentheses. X - lo / (hi - lo) divides lo first, a (4, 3) result of the right shape and the wrong values, 84.9 where 0.325 belonged.
  • DON'T reorder, add or drop a column of X without doing the same to lo and hi. They pair with the features by position alone, and a mismatch of equal length raises nothing.
  • DON'T read min-max output as proof that the data was in range. Given ranges map only the values inside them onto 0 to 1.

Lab Exercises

Lab Exercise 1: Correct Each Source, Then Measure Each Listing

Here are five listings, each with three estimates in thousands from an agent, the tax office and an online service, along with the lesson's three offsets and the five sale prices:

import numpy as np

est5 = np.array([[280, 255, 300],
                 [350, 310, 372],
                 [199, 180, 215],
                 [460, 400, 480],
                 [315, 290, 330]])
offset = np.array([10, -20, 15])
price5 = np.array([276, 340, 205, 450, 318])

Compute the offset-corrected estimates and each source's error on each listing, writing beside each line the shape you expect before you run it. Print both results with their shapes, and name in a comment the source that misses by the most on the fourth listing. Last, try the sale prices as they come, est5 - price5, inside try and except ValueError, and print the message.

Lab Exercise 2: Repair a Residual Table, Then Scale a Feature Table

A model's predictions for the same five listings arrive as a column:

import numpy as np

pred = np.array([[290], [345], [210], [455], [300]])
price5 = np.array([276, 340, 205, 450, 318])

Compute pred - price5, print its shape and its mean, and explain in a comment why it holds 25 numbers. Then compute the residuals correctly two ways, once by flattening pred with ravel and once by giving price5 a column shape with np.newaxis, printing each result's shape, and end with an assert that would stop the script if the flattened residuals were not shaped like price5.

Then scale this table of five listings, area_sqm, rooms and age, with the documented ranges 20 to 220 square meters, 1 to 6 rooms and 0 to 100 years:

import numpy as np

X5 = np.array([[110, 4, 18],
               [ 58, 2, 35],
               [205, 6,  3],
               [ 76, 3, 52],
               [ 40, 1, 90]])

Print the scaled table, then scale the single listing [230, 5, 8] with the same ranges and say in a comment which value left the range 0 to 1 and why nothing warned.


Solution 1: Correct Each Source, Then Measure Each Listing
import numpy as np

est5 = np.array([[280, 255, 300],
                 [350, 310, 372],
                 [199, 180, 215],
                 [460, 400, 480],
                 [315, 290, 330]])
offset = np.array([10, -20, 15])
price5 = np.array([276, 340, 205, 450, 318])

corrected = est5 - offset          # expect (5, 3), per source
err = est5 - price5.reshape(5, 1)  # expect (5, 3), per listing
print(corrected)
print(err)
print(corrected.shape, err.shape)
# Fourth listing: the tax office misses by the most, 50 below.

try:
    est5 - price5
except ValueError as e:
    print(e)
[[270 275 285]
 [340 330 357]
 [189 200 200]
 [450 420 465]
 [305 310 315]]
[[  4 -21  24]
 [ 10 -30  32]
 [ -6 -25  10]
 [ 10 -50  30]
 [ -3 -28  12]]
(5, 3) (5, 3)
operands could not be broadcast together with shapes (5,3) (5,) 

DON'T:

  • DON'T pass price5 to the table as it comes. Five prices against three sources raise here, and the same habit on a square table returns numbers without an error.
  • DON'T read the two (5, 3) results as the same kind of thing. The first is still prices, corrected per source, and the second is errors, one per listing per source.
Solution 2: Repair a Residual Table, Then Scale a Feature Table
import numpy as np

pred = np.array([[290], [345], [210], [455], [300]])
price5 = np.array([276, 340, 205, 450, 318])

wrong = pred - price5
print(wrong.shape, wrong.mean())  # (5, 5) 2.2
# 25 = every one of 5 predictions against every one of 5 prices.

flat = pred.ravel() - price5      # both (5,)
print(flat, flat.shape)           # [ 14   5   5   5 -18] (5,)
cols = pred - price5[:, np.newaxis]
print(cols.shape)                 # (5, 1)
assert flat.shape == price5.shape

The wrong table's mean, 2.2, is also the mean of the five real residuals, which is why its shape had to be printed beside it.

import numpy as np

X5 = np.array([[110, 4, 18],
               [ 58, 2, 35],
               [205, 6,  3],
               [ 76, 3, 52],
               [ 40, 1, 90]])
lo = np.array([20, 1, 0])
hi = np.array([220, 6, 100])

print((X5 - lo) / (hi - lo))
one = np.array([230, 5, 8])
print((one - lo) / (hi - lo))
# 230 square meters is past the 220 limit, so area scales to 1.05.
# Nothing warned: the shapes paired, and the rule checks nothing else.
[[0.45  0.6   0.18 ]
 [0.19  0.2   0.35 ]
 [0.925 1.    0.03 ]
 [0.28  0.4   0.52 ]
 [0.1   0.    0.9  ]]
[1.05 0.8  0.08]

DON'T:

  • DON'T reshape one side of a residual until the error stops. The column against the vector never raised, so the fix is one shape on both sides, asserted.
  • DON'T take the ranges from the five listings themselves here. Documented ranges scale a new listing by the same rule as the old ones.

Theory Assessment

Q1: The Rule, Applied Without Running Anything

State the broadcasting rule in your own words: where the shapes are aligned, when two sizes pair, and which size the result takes. Then give the result shape of each pairing, or say that it raises, and name which operand is reused along which dimension: (6, 4) with (4,), (6, 4) with (6,), (6, 1) with (1, 4), and (2, 6, 4) with (6, 1).

Hint: Write each pair one shape above the other, aligned on the right, and fill every missing place on the left with a 1 before comparing anything.

Q2: The Square Table That Passed Its Check

A colleague holds three sources' price estimates for three listings in a (3, 3) table est3 and the three sale prices in price3, shape (3,). They run est3 - price3, confirm that the top-left value is the first listing's first estimate minus its price, and move on. Explain what the line computed, why it raised nothing, why the check passed, and which line computes what was meant. Then say what the same line does with four listings.

Hint: Ask which of the table's two sizes price3's only size was paired with. Then compare what the value in row i and column i meets under each reading.

Q3: One Column, One Vector, Sixteen Numbers

For price = np.array([305, 248, 410, 200]), give the shapes of price[:, np.newaxis], price[np.newaxis, :] and price.reshape(-1, 1). Then explain why est[:, [2]] - price, with est the lesson's (4, 3) table of estimates, returns sixteen numbers where four residuals were wanted, say where the four real residuals sit, and explain why its mean, 15.75, equals the mean of the four real residuals.

Hint: Both operands are reused here, each along the dimension where it has a 1 or nothing at all. For the mean, write out what averaging prediction i minus price j over every i and every j comes to.

Q4: Ranges Stored One Feature per Row

A teammate keeps the documented ranges as one row per feature, minimum then maximum, for area, rooms and age:

import numpy as np

ranges = np.array([[20, 220], [1, 6], [0, 100]])
lo = ranges[:, 0:1]
hi = ranges[:, 1:2]

A slice in the column place keeps the column dimension, so lo and hi are (3, 1). On the lesson's first three listings, X3 = np.array([[85, 3, 12], [62, 2, 30], [120, 4, 5]]), the line (X3 - lo) / (hi - lo) runs without an error and returns a (3, 3) array. Say what its values are, which ones give the mistake away, what the line does on all four of the lesson's listings, and what one change fixes it.

Hint: Settle first which dimension of X3 the three values of lo line up with. Then ask what correct min-max scaling can never return for listings that sit inside their ranges.


Answer 1:

Align the two shapes on the right and count a missing leading dimension as 1. Two sizes in the same place pair when they are equal or one of them is 1, and one place that does not pair raises a ValueError. Each place of the result takes the size that is not 1, or the shared size, and the operand with the 1 is reused along that length.

(6, 4) with (4,) gives (6, 4), the vector reused down six rows. (6, 4) with (6,) raises, because 6 meets 4. (6, 1) with (1, 4) gives (6, 4), the column reused across four places and the row down six. (2, 6, 4) with (6, 1) gives (2, 6, 4): 4 meets 1, 6 meets 6 and 2 meets a missing size, so the (6, 1) array is reused across the last dimension and for both blocks of the first.

DON'T decide a pairing by the first sizes. The rule starts on the right, and one place that does not pair sinks the whole operation.

Answer 2:

The line paired price3's only size with the table's last size, the sources, and computed est3[i, j] - price3[j]: every agent appraisal minus the first listing's price, every tax assessment minus the second's, every online estimate minus the third's. Nothing raised because 3 meets 3, and the rule compares nothing but sizes. The check passed because the top-left value sits on the diagonal, where both readings agree.

The meant line is est3 - price3.reshape(3, 1). With four listings the same line meets (4, 3) with (4,) and raises ValueError: operands could not be broadcast together with shapes (4,3) (4,), so the larger table catches the mistake that the square one hides.

DON'T accept a square result because its shape is right. Both readings return (3, 3), so only a value off the diagonal, checked by hand, tells them apart.

Answer 3:

price[:, np.newaxis] is (4, 1), price[np.newaxis, :] is (1, 4), and price.reshape(-1, 1) is (4, 1) again.

A one-item list in the column place keeps the column dimension, so est[:, [2]] is a (4, 1) column. Against the (4,) prices, the column's 1 meets 4 and the vector's missing size meets the column's 4, so both are reused and the result is (4, 4), entry i, j being prediction i minus price j. The four real residuals are the diagonal, 20, 2, 32 and 9.

In the average of all sixteen, each prediction appears four times and each price four times, so it comes to the mean prediction minus the mean price, 306.5 minus 290.75, which is 15.75. The real residuals average to the same quantity, because the mean of differences is the difference of means, so the mean cannot expose the mistake, and the shape can.

DON'T fix a mismatch by reshaping one side until the error disappears. A column against a vector never raises, so make both sides 1-D or both columns, and assert the result's shape.

Answer 4:

lo and hi are (3, 1), so their 3 lines up with the listings and their 1 is reused across the features: listing 0 is scaled entirely with area's range, listing 1 with rooms' and listing 2 with age's. print((X3 - lo) / (hi - lo)) shows it:

[[ 0.325 -0.085 -0.04 ]
 [12.2    0.2    5.8  ]
 [ 1.2    0.04   0.05 ]]

The giveaway is every value outside 0 to 1, because a listing inside its ranges scales into 0 to 1. The diagonal, 0.325, 0.2 and 0.05, is correct, so the first value checks out, the square-table trap again. On all four listings, 4 meets 3 and the line raises ValueError: operands could not be broadcast together with shapes (4,3) (3,1). The fix is an integer index instead of a slice, lo = ranges[:, 0] and hi = ranges[:, 1], whose (3,) vectors line up with the features, and the line then returns the first three rows of the lesson's scaled table.

DON'T store a per-feature value as a (k, 1) column to be safe. That shape turns it into a per-listing value, and on a table with as many listings as features nothing raises.


Assignment

Assignment: A Scaler That Refuses the Wrong Shape

Scaling code gets reused on inputs its author never pictured: a table one day, one new listing the next, and later a listing somebody stored as a column. Write minmax(X, lo, hi), which min-max scales X, given either as one listing of shape (k,) or as a table of shape (m, k), and unminmax(Z, lo, hi), which maps scaled values back to their units. Before computing anything, minmax raises a ValueError naming the shapes when lo and hi are not one 1-D shape, and when the result would not have the shape of X itself. It settles the second case from the shapes alone with np.broadcast_shapes(*shapes), which applies this lesson's rule to shape tuples without building an array: it returns the shape the operation would produce, or raises a ValueError when the shapes cannot pair.

Then, with the lesson's ranges, 20 to 220 square meters, 1 to 6 rooms and 0 to 100 years:

  1. Scale the batch [[95, 3, 7], [150, 5, 29], [70, 2, 60]] and the listing [95, 3, 7], printing both with their shapes, and check with Lesson 2.2.3's np.allclose that unminmax brings the batch back.
  2. Print what the bare (X - lo) / (hi - lo) returns for that listing stored as a column, listing.reshape(-1, 1), and what np.broadcast_shapes predicts for it.
  3. Show minmax refusing that column, a listing with only two features, [95, 3], and the batch with its ranges passed as (3, 1) columns, catching and printing each error.

Hints:

  • np.broadcast_shapes(X.shape, lo.shape) returns a tuple, so comparing it with X.shape takes one !=, and the same test serves a (k,) listing and an (m, k) table.
  • Check the ranges before anything else. Against a square table, (3,) ranges and (3, 1) ranges both predict the table's own shape, and lo.ndim tells them apart.

Solution:
import numpy as np

def minmax(X, lo, hi):
    if lo.ndim != 1 or lo.shape != hi.shape:
        raise ValueError(f"lo and hi must share one 1-D shape, got {lo.shape} and {hi.shape}")
    out = np.broadcast_shapes(X.shape, lo.shape)
    if out != X.shape:
        raise ValueError(f"result would be {out}, not the {X.shape} of X")
    return (X - lo) / (hi - lo)

def unminmax(Z, lo, hi):
    return Z * (hi - lo) + lo

lo = np.array([20, 1, 0])
hi = np.array([220, 6, 100])
batch = np.array([[95, 3, 7], [150, 5, 29], [70, 2, 60]])
listing = np.array([95, 3, 7])

Z = minmax(batch, lo, hi)
print(Z, Z.shape)
print(minmax(listing, lo, hi), minmax(listing, lo, hi).shape)
print(np.allclose(unminmax(Z, lo, hi), batch))
[[0.375 0.4   0.07 ]
 [0.65  0.8   0.29 ]
 [0.25  0.2   0.6  ]] (3, 3)
[0.375 0.4   0.07 ] (3,)
True

Steps 2 and 3 continue the same script:

column = listing.reshape(-1, 1)
print((column - lo) / (hi - lo))
print(np.broadcast_shapes(column.shape, lo.shape))

cases = [(column, lo, hi), (np.array([95, 3]), lo, hi),
         (batch, lo.reshape(3, 1), hi.reshape(3, 1))]
for X, a, b in cases:
    try:
        minmax(X, a, b)
    except ValueError as e:
        print("refused:", e)
[[ 0.375 18.8    0.95 ]
 [-0.085  0.4    0.03 ]
 [-0.065  1.2    0.07 ]]
(3, 3)
refused: result would be (3, 3), not the (3, 1) of X
refused: shape mismatch: objects cannot be broadcast to a single shape.  Mismatch is between arg 0 with shape (2,) and arg 1 with shape (3,).
refused: lo and hi must share one 1-D shape, got (3, 1) and (3, 1)

The column would have broadcast into nine numbers for one listing, and np.broadcast_shapes predicted that (3, 3) from two tuples, before any arithmetic ran. The short listing never reached the comparison, because np.broadcast_shapes raised on its own, naming both shapes by argument position. The (3, 1) ranges are the case a prediction alone would pass, since against the square batch they predict (3, 3), the batch's own shape, which is why the ranges are checked first.

DON'T guard a broadcast by waiting for its error. A listing stored as a column raises nothing, so predict the result's shape from the input shapes and refuse anything that would not keep the shape of X.


Additional Resources

Optional, and nothing later depends on them. Worth a look if you want the habit of guarding shapes argued from another angle, or scaling set beside its alternatives.

  • YouTube - "A Note on Python/Numpy Vectors (C1W2L16)" by DeepLearningAI. Andrew Ng's case for avoiding 1-D vectors in model code altogether, and for asserting shapes as you go.
  • Google Machine Learning Crash Course - "Numerical data: Normalization", the "Linear scaling" section. Min-max scaling under another name, followed on the same page by Z-score scaling, log scaling and clipping, and when each one fits.

Summary

Broadcasting is a rule about sizes and nothing else. Two shapes meet on the right, each place pairs or does not, and a size of 1 is reused wherever the other side is longer. Everything this lesson built, offsets removed per source, errors measured per listing, features squeezed onto one scale, was that check passing for a reason you chose. The rule never knew what the numbers meant, which is why one rule served both directions and could not tell them apart.

That blindness is the real subject. The loud failure, a ValueError naming two shapes, is the kind one, because it stops the script on the line that went wrong. The quiet ones return arrays that look like answers, a square table corrected in the wrong direction with its diagonal still right, a column of predictions against a vector of prices grown into every possible pairing while its average stays honest. Each is the rule doing precisely what it says, and the defense never changes: decide what a vector belongs to, give it the shape that says so, and read the result's shape before trusting a value in it.

A value can be right by coincidence, and a shape only on a square table, which is why checking shapes now outranks checking numbers. The ranges that closed the lesson were handed to you, and the next lesson computes such values from the data itself, collapsing a table along the dimension you name, the opposite movement to the reuse broadcasting performs.


Cheat Sheet

Part A - Reference Table

Concept Syntax Shapes Notes
The rule align on the right missing size counts as 1 equal or 1 pairs, else ValueError
Per feature X - v (n, k) with (k,) v reused down every row
Per listing X - v.reshape(-1, 1) (n, k) with (n, 1) v reused across every column
Square trap X - v, n equal to k (k, k) with (k,) per feature, diagonal still right
Column, row v[:, np.newaxis], v[np.newaxis, :] (n, 1), (1, n) v[:, None] is the same column
Outer trap col - v (n, 1) with (n,) (n, n), no error, mean still the true mean error
Keep a column X[:, [j]] (n, 1) X[:, j] gives (n,)
Min-max (X - lo) / (hi - lo) lo, hi are (k,) out-of-range values leave 0 to 1
Predict a shape np.broadcast_shapes(a.shape, b.shape) shape tuples in, one out raises ValueError when they cannot pair

Part B - Compact Code Block

import numpy as np

est = np.array([[310, 280, 325], [245, 231, 250]])
price = np.array([305, 248])
offset = np.array([10, -20, 15])

est - offset                       # per column, (2, 3) with (3,)
est - price[:, np.newaxis]         # per row, (2, 3) with (2, 1)
est[:, [2]] - price                # (2, 2) outer table, no error
assert (est[:, 2] - price).shape == price.shape

lo, hi = np.array([20, 1, 0]), np.array([220, 6, 100])
X = np.array([[85, 3, 12], [62, 2, 30]])
Z = (X - lo) / (hi - lo)           # min-max per feature
np.broadcast_shapes((3, 1), (3,))  # (3, 3), predicted, no arrays
←Previous: Lesson 3
Next: Lesson 5→

Questions about this lesson: #module-2 on Discord

Found an error in this lesson? Report it

Your progress
Current streak
🔥 0
Lessons marked today
0 / 3
Longest streak
0

Sign in

The bootcamp

  • Curriculum
  • How To
  • Pricing
  • Changelog
  • Future

The community

  • Discord
  • Reviews
  • Contact

Follow

  • Instagram
  • X
  • TikTok
  • Reddit

Legal

  • Terms
  • Privacy
  • Refunds

Coming soon on November 16, 2026

Theme