{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Chapter 6: NumPy basics\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/01-python-basics/06-numpy.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/01-python-basics/06-numpy.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "A Python loop can normalise 2,400 exam scores. It can. It takes thirty lines, runs ten times slower than it should, and looks nothing like the code the person reviewing your pull request expects to see.\n", "\n", "NumPy does it in one: `(student_data - student_data.mean(axis=0)) / student_data.std(axis=0)`. No loop. No temporary variables. The operation, stated directly.\n", "\n", "That shape, a short expression operating on an entire matrix, is the syntax that Pandas, scikit-learn, and PyTorch all speak. The standard library tools from Chapter 5 handle exact arithmetic on small collections; this chapter introduces the data structure that handles numbers at scale. Chapter 7 (`07-numpy-advanced.ipynb`) extends it with broadcasting, vectorisation, and linear algebra.\n", "\n", "> Callout markers: [book cover page](../../index.qmd#callout-guide)." ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "## Meet NumPy\n", "\n", "NumPy ([numpy.org](https://numpy.org)) was created in 2005 by Travis Oliphant to give Python scientists the numeric performance of Fortran and C without leaving the language. The idea was simple: store numbers in a contiguous block of memory with a fixed type, and let a thin Python wrapper call heavily optimised C and Fortran routines on that block. Two decades later, nearly every numerical computing library in Python (pandas, scikit-learn, PyTorch, TensorFlow) uses NumPy arrays as its currency.\n", "\n", "### How it compares\n", "\n", "| Approach | Speed on large arrays | Readable math | When to use |\n", "| --- | --- | --- | --- |\n", "| Python `list` + loop | Slow (Python objects, GIL) | Verbose | Small collections, mixed types |\n", "| **NumPy `ndarray`** | **Fast (C/Fortran, contiguous)** | **Concise (`a * 2`)** | **Numeric data of any size** |\n", "| PyTorch `Tensor` | Fast (optionally GPU) | Similar to NumPy | Deep learning, autodiff |\n", "| JAX `Array` | Very fast (XLA, JIT, GPU/TPU) | NumPy-compatible | Research, differentiable programs |\n", "| CuPy `ndarray` | GPU only | NumPy-compatible | Large-scale GPU computing |\n", "\n", "For everything up to classical ML on a laptop, NumPy is the right level of abstraction. PyTorch and JAX add complexity (device management, gradient tracking) that you don't need yet.\n", "\n", "### Already in your environment\n", "\n", "NumPy is included in `pyproject.toml`. If you ever start a standalone project:\n", "\n", "```bash\n", "uv add numpy # or: pip install numpy\n", "```\n", "\n", "Official docs and API reference: [numpy.org/doc](https://numpy.org/doc/stable/)" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "::: {.callout-note collapse=\"true\" icon=false}\n", "## Learning Objectives\n", "\n", "| # | Skill | Covered in |\n", "|---|---|---|\n", "| 1 | Explain why NumPy arrays outperform Python lists for numeric data | Sec. 1 |\n", "| 2 | Create arrays with `array`, `arange`, `zeros`, `ones`, and a `Generator` | Sec. 2 |\n", "| 3 | Inspect and reshape arrays using `shape`, `dtype`, `reshape`, and stacking | Sec. 3 |\n", "| 4 | Select data with integer indexing, slicing, and fancy indexing | Sec. 4 |\n", "| 5 | Filter arrays with boolean masks and `np.where` | Sec. 5 |\n", "| 6 | Compute per-column and per-row statistics with `axis` | Sec. 6 |\n", ":::" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "## 1. Why NumPy? The `ndarray`\n", "\n", "A Python `list` can hold anything (mixed types, nested objects), which makes it flexible but slow for numeric work: every element is a separate Python object, and arithmetic on a list means a Python-level loop.\n", "\n", "A NumPy **`ndarray`** (\"n-dimensional array\") is different: it stores **one fixed dtype** in a single contiguous block of memory. That uniformity lets NumPy hand the math off to compiled C/Fortran loops instead of the Python interpreter, often 10-100x faster, and with far less memory per element." ] }, { "cell_type": "code", "execution_count": null, "id": "6", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "\n", "study_hours = [12, 5, 18, 9, 22]\n", "\n", "# A Python list has no element-wise arithmetic: this is string repetition, not math!\n", "print(\"list * 2 :\", study_hours * 2)\n", "\n", "hours = np.array(study_hours)\n", "print(\"array * 2 :\", hours * 2) # element-wise multiplication" ] }, { "cell_type": "markdown", "id": "7", "metadata": {}, "source": [ "{fig-alt=\"Two panels. Left red panel: Python list with pointer boxes and scattered PyObject heap cells. Right blue panel: NumPy ndarray with a contiguous block of float64 values labeled one cache line.\"}" ] }, { "cell_type": "markdown", "id": "8", "metadata": {}, "source": [ "
dtype for every element) and has a fixed shape. Because elements sit next to each other in memory, NumPy can vectorise operations: apply one compiled loop to the whole array instead of looping in Python.scores_list = [78, 85, 92, 91, 55] and scores_arr = np.array(scores_list).(scores_arr - scores_arr.mean()) / scores_arr.std()..mean() and .std().scores_list - scores_list[0] and note the error.[-0.3, 0.3, 1.1, 0.9, -2.0].\n",
"rng = np.random.default_rng(42) creates an independent, reproducible stream. The legacy np.random.seed(42) sets global state shared by every library in your process: a hidden coupling that makes results hard to reproduce. Prefer default_rng for all new code.\n",
"default_rng over np.random.seednp.random.seed(42) mutates a single global random state shared by your whole program. Any other code (or library) calling np.random.* shifts that shared state and breaks your reproducibility. rng = np.random.default_rng(42) gives you an isolated generator: pass it around explicitly, and your results stay reproducible no matter what else runs.\n",
"rng = np.random.default_rng(7), generate a student_data array of shape (50, 3) for 50 students with columns:study_hours: rng.uniform(0, 25, size=50)attendance_pct: rng.uniform(50, 100, size=50)prior_gpa: rng.uniform(2.0, 4.0, size=50)(50, 3) array with np.column_stack.\n",
"student_data.shape # -> (50, 3)\n",
"student_data[0] # -> array([study_hours_0, attendance_pct_0, prior_gpa_0])\n",
"Hint: np.column_stack([a, b, c]) stacks 1D arrays as columns of a 2D array.\n",
"(5, 3) array minus a (3, 5) array either errors or broadcasts in a way you did not intend. Print arr.shape, arr.dtype whenever a result looks wrong.\n",
"np.array([1, 2, 3.5]) produces a float64 array, not a mix of int and float. NumPy must pick one dtype for the whole array and silently widens every element to fit. This is usually harmless, but np.array([1, 2, 3], dtype=np.int32) / 2 truncating, or an unexpected int8 overflowing past 127, are the same root cause: always check .dtype when results look wrong.\n",
"reshape() returns a view, flatten() returns a copy.reshape() mutates the original array too. They share the same underlying memory. .flatten() always copies, so mutating it is safe. This distinction (view vs. copy) comes up constantly in NumPy; Sec. 11 covers it in more depth.\n",
"study_hours = np.array([12, 5, 18, 9, 22])attendance = np.array([85, 60, 95, 70, 98])gpa = np.array([3.1, 2.4, 3.8, 2.9, 3.9])(5, 3) matrix with np.column_stack..shape and .dtype.(3, 5) and confirm shapes swapped.(5, 3), reshaped (3, 5).\n",
"sub = student_data[:2] does not copy data: sub shares memory with student_data. Modifying sub[0, 0] = 99 also changes student_data[0, 0]. Call .copy() when you need an independent array.\n",
"student_data[:, 1] (a slice) returns a view: mutating it mutates student_data. student_data[1:4, [0, 2]] (a list of indices, known as \"fancy indexing\") always returns a copy. If you need an independent array from a slice, call .copy() explicitly: col = student_data[:, 1].copy().\n",
"student_data array above (columns: study_hours, attendance_pct, prior_gpa), use slicing to print:prior_gpa column (all rows)study_hours and prior_gpa columns (skip attendance) for every rowstudent_data[:, [0, 2]].\n",
"scores >= 70 and attendance >= 80 raises ValueError on arrays. Write (scores >= 70) & (attendance >= 80). The parentheses matter because & binds tighter than >=.\n",
"scores and attendance arrays, build a boolean mask needs_help that flags students with score < 70 or attendance < 60, then print how many students were flagged and their scores.\n",
"scores = [62, 78, 85, 91, 55, 73, 88, 95, 67, 80]\n",
"attendance = [85, 60, 95, 70, 98, 45, 88, 92, 55, 80]\n",
"# needs_help -> True at indices 0, 4, 5, 8 (score<70 OR attendance<60)\n",
"student_data.mean(axis=0) gives one value per column: per-column statistics. student_data.mean(axis=1) gives one value per row: per-row statistics. Omitting axis collapses everything to a single scalar.\n",
"axis=0\" is easy to misremember. Read it as: \"collapse axis 0 (the row axis): what's left is one value per column.\" If you want one statistic per column (the usual case before normalising a student data matrix), that is always axis=0.\n",
"student_data array from Activity 3:axis=0).axis=1).student_data_z = (student_data - student_data.mean(axis=0)) / student_data.std(axis=0)student_data_z.mean(axis=0) is all zeros (within floating-point tolerance).\n",
"07-numpy-advanced.ipynb) builds on these with the four topics that make NumPy genuinely powerful: broadcasting, vectorisation, linear algebra, and common gotchas.\n",
"students dataset below:(6, 3) array student_data with np.column_stackstudent_data (Sec. 6)exam_score with the given weights/bias using @ (Sec. 9), applied to the normalised dataactual_scores using np.linalg.norm