PythonMastery
beginner 18 min read · lesson 3 of 6 in Data Science Fundamentals

NumPy: Working with Numbers

1 · The lesson

read

A Python list of a million numbers is flexible but slow. A NumPy array of a million numbers does maths 10 to 100 times faster, uses a fraction of the memory, and reads more like the formula you'd write on paper. NumPy is the foundation everything else in the DS stack sits on — pandas, scikit-learn, PyTorch — they all return or accept NumPy arrays.

This lesson gives you the working subset. The full NumPy track goes deeper.


1. Why NumPy Exists

python
import numpy as np
import time

n = 1_000_000

# Pure Python
py = list(range(n))
t0 = time.perf_counter()
py_doubled = [x * 2 for x in py]
py_time = time.perf_counter() - t0

# NumPy
arr = np.arange(n)
t0 = time.perf_counter()
arr_doubled = arr * 2
np_time = time.perf_counter() - t0

print(f"Python list: {py_time*1000:.1f} ms")
print(f"NumPy array: {np_time*1000:.1f} ms")
print(f"Speedup:     {py_time/np_time:.0f}x")

The speedup comes from two places: NumPy stores numbers in a contiguous typed buffer (no Python object overhead per element), and operations like arr * 2 run in compiled C. You write Python; the loops happen below your code.

The convention is import numpy as np. Always. Every example online assumes it.


2. Creating Arrays

python
import numpy as np

# From a list
a = np.array([1, 2, 3, 4, 5])
print(a)                              # [1 2 3 4 5]

# Constructors
zeros = np.zeros(5)                   # [0. 0. 0. 0. 0.]   floats by default
ones  = np.ones((2, 3))               # 2x3 matrix of 1.0
empty = np.empty(3)                   # uninitialised — values are garbage, fast
full  = np.full(4, 7.5)               # [7.5 7.5 7.5 7.5]

# Ranges
r1 = np.arange(0, 10)                 # [0 1 2 3 4 5 6 7 8 9]   like range
r2 = np.arange(0, 1, 0.25)            # [0.  0.25 0.5  0.75]
r3 = np.linspace(0, 1, 5)             # [0.   0.25 0.5  0.75 1. ]  N points incl. ends

# Random
rng = np.random.default_rng(42)       # seedable generator (modern API)
print(rng.random(3))                  # 3 floats in [0, 1)
print(rng.integers(1, 7, size=10))    # 10 dice rolls

arange excludes the stop; linspace includes both ends and takes a count. Use linspace for plot axes; arange for indices.


3. dtype, shape, ndim, size

Four attributes describe any array — memorise these four and you can read most NumPy code.

python
a = np.array([[1, 2, 3],
              [4, 5, 6]])

print(a.dtype)                        # int64    — every cell same type
print(a.shape)                        # (2, 3)   — rows, columns
print(a.ndim)                         # 2        — number of dimensions
print(a.size)                         # 6        — total cells
print(a.itemsize)                     # 8        — bytes per cell
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

Common dtypes: int64, float64, bool, int32, float32. Force a dtype when you need to save memory or match an API:

python
b = np.array([1, 2, 3], dtype=np.float32)
print(b.dtype)                        # float32
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

A mixed-type list silently produces an object array — slow and useless for maths. Watch for it:

python
bad = np.array([1, "two", 3.0])
print(bad.dtype)                      # <U21 — string array, not numbers
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

4. Vectorised Operations — No For Loops

Every arithmetic operator works elementwise. So do the comparisons. So do the maths functions.

python
a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])

print(a + b)                          # [11 22 33 44 55]
print(a * 2)                          # [ 2  4  6  8 10]
print(b / a)                          # [10. 10. 10. 10. 10.]
print(a ** 2)                         # [ 1  4  9 16 25]

print(np.sqrt(a))                     # [1.    1.414 1.732 2.    2.236]
print(np.log(a))                      # natural log of each
print(np.exp(a))                      # e^x  of each

# Comparisons return boolean arrays
print(a > 2)                          # [False False  True  True  True]
print((a > 1) & (a < 5))              # element-wise AND — note &, not `and`
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

The rule: if you find yourself writing a for-loop over a NumPy array, you're probably doing it wrong. Express the operation on the whole array.


5. Aggregations

Reduce an array to one number — or to one number per row / column.

python
a = np.array([[1, 2, 3],
              [4, 5, 6]])

print(a.sum())                        # 21
print(a.mean())                       # 3.5
print(a.max())                        # 6
print(a.min())                        # 1
print(a.std())                        # 1.707...  population stddev
print(a.var())                        # 2.916...

# axis=0 collapses rows → result per column
print(a.sum(axis=0))                  # [5 7 9]
# axis=1 collapses columns → result per row
print(a.sum(axis=1))                  # [ 6 15]
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

axis=0 is "down the rows". axis=1 is "across the columns". The collapsed dimension disappears from the output shape.


6. Indexing and Slicing

For 1D arrays, indexing matches Python lists.

python
a = np.arange(10, 60, 10)             # [10 20 30 40 50]
print(a[0])                           # 10
print(a[-1])                          # 50
print(a[1:4])                         # [20 30 40]
print(a[::2])                         # [10 30 50]   every other element
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

For 2D, give two indices — row then column:

python
m = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

print(m[0, 0])                        # 1
print(m[1, 2])                        # 6
print(m[:, 0])                        # [1 4 7]      first column
print(m[1, :])                        # [4 5 6]      second row
print(m[0:2, 1:3])                    # [[2 3]
                                      #  [5 6]]
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

m[1, :] is the row at index 1. m[:, 0] is the column at index 0. The colon means "all of this axis".


7. Boolean Masking

Pick elements that satisfy a condition. This is the single most useful indexing trick.

python
a = np.array([12, 5, 9, 20, 3, 17])

mask = a > 10
print(mask)                           # [ True False False  True False  True]
print(a[mask])                        # [12 20 17]

# In one line — common pattern
print(a[a > 10])                      # [12 20 17]

# Combine conditions with & and | (parentheses required)
print(a[(a > 5) & (a < 18)])          # [12  9 17]

# Assign through a mask — clip values in place
a[a < 5] = 0
print(a)                              # [12  5  9 20  0 17]
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

Boolean masking is how you filter a column. In pandas it looks identical — df[df["age"] > 30].


8. Reshaping

Same data, different shape. The total number of elements must match.

python
a = np.arange(12)
print(a.shape)                        # (12,)

b = a.reshape(3, 4)
print(b)
# [[ 0  1  2  3]
#  [ 4  5  6  7]
#  [ 8  9 10 11]]

c = a.reshape(2, 2, 3)                # 3D — 2 layers of 2x3
print(c.shape)                        # (2, 2, 3)

# -1 means "figure this dimension out"
d = a.reshape(4, -1)                  # 4 rows, NumPy fills in 3 cols
print(d.shape)                        # (4, 3)
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

reshape returns a view where possible — no data is copied. Cheap.


9. A Teaser: Broadcasting

What happens when shapes don't match?

python
prices = np.array([[10, 20, 30],
                   [40, 50, 60]])
tax_rate = 0.2

# Scalar + array
print(prices * (1 + tax_rate))
# [[12. 24. 36.]
#  [48. 60. 72.]]

# Row vector + 2D — broadcasts down the rows
discount = np.array([0.9, 0.95, 1.0])
print(prices * discount)
# [[ 9.  19.  30. ]
#  [36.  47.5 60. ]]
+ setup added so this can run · defines np
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

np = _AutoMock('np')

NumPy stretches the smaller shape to match the larger one without copying. That's broadcasting, and it's the deep magic behind concise array code. Full treatment in the advanced NumPy lesson.


Common Mistakes

  • Pythonic for-loops over arrays. for x in arr: arr[i] = x * 2 instead of arr * 2. Defeats every speed advantage NumPy offers.
  • Mixed-type arrays become object dtype. np.array([1, "two", 3]) gives you a string array. Coerce types before constructing.
  • Integer arrays surprise on division. np.array([1, 2, 3]) / 2 is fine (returns float). np.array([1, 2, 3]) // 2 truncates. Mind the difference.
  • Using and / or on arrays. They raise ValueError. Use & and | with parenthesised operands.
  • Forgetting axis=. a.sum() collapses the whole array. a.sum(axis=0) returns one value per column.
  • Confusing np.arange with np.linspace. arange takes a step, linspace takes a count. Use linspace when you care about the endpoints.

🎯 Your Turn — normalize()

Implement z-score normalisation: for each value subtract the mean and divide by the standard deviation. A normalised array has mean 0 and stdev 1 — a preprocessing step you'll do constantly before ML.

Skeleton:

python
import numpy as np

def normalize(arr):
    """Return (arr - mean) / std. Handle std == 0 sensibly."""
    arr = np.asarray(arr, dtype=float)
    # TODO 1: compute mean and std
    # TODO 2: subtract and divide — vectorised, no loops
    ...

# Test
data = np.array([10, 20, 30, 40, 50])
z = normalize(data)
print(z)                              # roughly [-1.41 -0.71  0.    0.71  1.41]
print(z.mean(), z.std())              # ~0.0, 1.0
Hint 1 — Two numbers, then one expression Compute m = arr.mean() and s = arr.std() first. Then return (arr - m) / s. NumPy broadcasts the scalars across the whole array — no loop needed.
Hint 2 — Constant arrays If every element is the same, std is 0 and you'd divide by zero. Decide what to do: return zeros, raise ValueError, or replace std with 1. Returning zeros is the kindest default.
Show full solution
python
import numpy as np

def normalize(arr):
    arr = np.asarray(arr, dtype=float)
    mean = arr.mean()
    std  = arr.std()
    if std == 0:
        return np.zeros_like(arr)     # all values identical — no spread to scale
    return (arr - mean) / std

data = np.array([10, 20, 30, 40, 50])
z = normalize(data)
print(z)                              # [-1.41 -0.71  0.    0.71  1.41]
print(round(z.mean(), 10), round(z.std(), 10))   # 0.0, 1.0

# Edge case
print(normalize([7, 7, 7]))           # [0. 0. 0.]

Three lines of real work — np.asarray, two scalar reductions, one broadcast subtraction-and-divide. Scikit-learn's StandardScaler is the same calculation with a fit/transform interface around it.


What You Learned

  • NumPy arrays are contiguous, typed, and fast — 10-100x quicker than Python lists for numeric work.
  • Four attributes describe an array: dtype, shape, ndim, size.
  • Vectorised ops replace for-loops — a + b, a * 2, np.sqrt(a).
  • Aggregations collapse arrays; axis= controls which dimension disappears.
  • Boolean masking filters by condition: a[a > 5].
  • Reshape restructures data without copying.
  • Broadcasting lets differently-shaped operands combine — the dark art of concise array code.

Next: Pandas: Working with Tables — NumPy with column names, indexes, and the I/O you actually need.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.