{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Chapter 4: Classes and patterns\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/01-python-basics/04-classes.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/01-python-basics/04-classes.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "A student record is not three separate variables; it is one thing. A class lets you treat it that way: one `__repr__()`, one place where the rules about valid scores live, one factory method that builds it from a raw CSV row.\n", "\n", "Chapter 3 built functions: the logic. A function only holds logic; the data it operates on lives somewhere else, as loose variables the function accepts and returns. A class bundles data and the functions that operate on it under one name.\n", "\n", "This chapter adds the tools that Chapter 3 could not carry: classes, dataclasses, NamedTuples, TypedDicts, exceptions, and file I/O. Chapter 5 (`05-math-statistics.ipynb`) uses these patterns throughout as it introduces the standard library math and statistics modules." ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "::: {.callout-note collapse=\"true\" icon=false}\n", "## Learning objectives\n", "\n", "By the end of Chapter 4 you will be able to:\n", "\n", "| # | Skill | Covered in |\n", "|---|---|---|\n", "| 1 | Define classes with `__init__`, `__repr__`, `@property`, and `@classmethod` | Sec. 1 |\n", "| 2 | Use inheritance and abstract base classes | Sec. 1 |\n", "| 3 | Create dataclasses and frozen dataclasses | Sec. 2 |\n", "| 4 | Use NamedTuple for immutable typed records | Sec. 3 |\n", "| 5 | Use TypedDict and type aliases for structured dicts | Sec. 4 |\n", "| 6 | Handle exceptions with `try`/`except`/`finally` and custom exception classes | Sec. 5 |\n", "| 7 | Read and write files with `pathlib`, `csv`, and `json` | Sec. 6 |\n", "| 8 | Recognise and avoid common Python gotchas | Sec. 7 |\n", ":::" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "## 1. Classes\n", "\n", "A **class** is a blueprint for creating objects. Each object (instance) bundles its own data (attributes) with the functions (methods) that operate on it. You create the blueprint once with `class`, then stamp out as many instances as you need.\n", "\n", "Three things make a class useful rather than just a namespace:\n", "- `__init__`: sets the initial state of each instance\n", "- `__repr__`: returns a readable string when you print or inspect the object\n", "- **methods**: functions that always have access to the instance's data via `self`" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "{fig-alt=\"Left: class StudentRecord box showing __init__, @property, @classmethod, __repr__ methods with colored role indicators. Right: alice instance showing attribute values after construction. Arrow from class to instance labeled instantiate.\"}" ] }, { "cell_type": "markdown", "id": "6", "metadata": {}, "source": [ "### `__init__` and `__repr__`\n", "\n", "Every class needs two methods before it's useful in interactive work. `__init__` runs the moment you create an instance: it receives the raw data and stores it on `self`. `__repr__` is what Python prints when you inspect the object in the REPL or call `print()` on it.\n", "\n", "Without `__repr__`, Python shows something like `<__main__.StudentRecord object at 0x7f3a2c1d4e50>`. That address tells you nothing. With a good `__repr__` you can see the name, program, and average in one glance, which makes debugging feel like reading, not guessing.\n", "\n", "The rule: `__repr__` should produce a string that is both human-readable and, where possible, copy-pasteable back into Python to re-create the object." ] }, { "cell_type": "code", "execution_count": null, "id": "7", "metadata": {}, "outputs": [], "source": [ "class StudentRecord:\n", " \"\"\"A single student's academic record.\n", "\n", " Args:\n", " name: Student's full name.\n", " scores: List of numeric assessment scores (0-100).\n", " program: Degree program name.\n", " \"\"\"\n", "\n", " def __init__(\n", " self,\n", " name: str,\n", " scores: list[float] | None = None,\n", " program: str = \"Undeclared\",\n", " ) -> None:\n", " self.name = name\n", " self.scores: list[float] = scores if scores is not None else []\n", " self.program = program\n", "\n", " def __repr__(self) -> str:\n", " avg = sum(self.scores) / len(self.scores) if self.scores else 0.0\n", " return f\"StudentRecord(name={self.name!r}, program={self.program!r}, avg={avg:.1f})\"" ] }, { "cell_type": "code", "execution_count": null, "id": "8", "metadata": {}, "outputs": [], "source": [ "alice = StudentRecord(\n", " name=\"Alice Kamau\",\n", " scores=[88.0, 92.0, 85.0, 90.0],\n", " program=\"Computer Science\",\n", ")\n", "bob = StudentRecord(\n", " name=\"Bob Mwangi\",\n", " scores=[62.0, 70.0, 58.0, 74.0],\n", " program=\"Mathematics\",\n", ")\n", "\n", "print(alice) # __repr__ in action: no print formatting needed\n", "print(bob)" ] }, { "cell_type": "markdown", "id": "9", "metadata": {}, "source": [ "### `@property`: computed attributes\n", "\n", "You could store the average as an attribute set in `__init__`: `self.average = sum(scores) / len(scores)`. The problem is stale state. If someone appends a score later, `student.scores.append(95.0)`, the stored average is now wrong, and nothing tells you.\n", "\n", "A `@property` computes the value fresh every time you access it. From the caller's side it looks exactly like reading a plain attribute: `student.average`, no parentheses. Under the hood Python calls the method. You get the right answer regardless of when scores were added.\n", "\n", "Use `@property` for any value that is derived from other attributes. Store it explicitly only if the computation is genuinely expensive and you have a caching strategy." ] }, { "cell_type": "code", "execution_count": null, "id": "10", "metadata": {}, "outputs": [], "source": [ "class StudentRecord:\n", " \"\"\"A single student's academic record with computed grade properties.\n", "\n", " Args:\n", " name: Student's full name.\n", " scores: List of numeric assessment scores (0-100).\n", " program: Degree program name.\n", " \"\"\"\n", "\n", " def __init__(\n", " self,\n", " name: str,\n", " scores: list[float] | None = None,\n", " program: str = \"Undeclared\",\n", " ) -> None:\n", " self.name = name\n", " self.scores: list[float] = scores if scores is not None else []\n", " self.program = program\n", "\n", " def __repr__(self) -> str:\n", " return f\"StudentRecord(name={self.name!r}, program={self.program!r}, avg={self.average:.1f})\"\n", "\n", " @property\n", " def average(self) -> float:\n", " \"\"\"Mean of all scores; 0.0 if no scores recorded.\"\"\"\n", " return sum(self.scores) / len(self.scores) if self.scores else 0.0\n", "\n", " @property\n", " def letter_grade(self) -> str:\n", " \"\"\"Letter grade derived from current average.\"\"\"\n", " avg = self.average\n", " if avg >= 90:\n", " return \"A\"\n", " if avg >= 80:\n", " return \"B\"\n", " if avg >= 70:\n", " return \"C\"\n", " if avg >= 60:\n", " return \"D\"\n", " return \"F\"" ] }, { "cell_type": "code", "execution_count": null, "id": "11", "metadata": {}, "outputs": [], "source": [ "alice = StudentRecord(\"Alice Kamau\", [88.0, 92.0, 85.0], \"Computer Science\")\n", "print(f\"Before: avg={alice.average:.1f} grade={alice.letter_grade}\")\n", "\n", "# Add a new score: the property recomputes on the fly\n", "alice.scores.append(95.0)\n", "print(f\"After : avg={alice.average:.1f} grade={alice.letter_grade}\")\n", "\n", "print(alice)" ] }, { "cell_type": "markdown", "id": "12", "metadata": {}, "source": [ "### `@classmethod` factory methods\n", "\n", "A `@classmethod` is an alternative constructor: it builds an instance from a different kind of input without requiring the caller to know what `__init__` expects.\n", "\n", "The pattern is useful whenever your data arrives in a format different from what `__init__` expects. A CSV row is a `dict[str, str]` where every value is a string. Putting the parsing logic inside a `@classmethod` keeps `__init__` clean and makes the intent explicit at the call site: `StudentRecord.from_dict(row)` reads like documentation." ] }, { "cell_type": "code", "execution_count": null, "id": "13", "metadata": {}, "outputs": [], "source": [ "class StudentRecord:\n", " \"\"\"A student record with factory method support.\n", "\n", " Args:\n", " name: Student's full name.\n", " scores: List of numeric assessment scores (0-100).\n", " program: Degree program name.\n", " \"\"\"\n", "\n", " def __init__(\n", " self,\n", " name: str,\n", " scores: list[float] | None = None,\n", " program: str = \"Undeclared\",\n", " ) -> None:\n", " self.name = name\n", " self.scores: list[float] = scores if scores is not None else []\n", " self.program = program\n", "\n", " def __repr__(self) -> str:\n", " return f\"StudentRecord(name={self.name!r}, program={self.program!r}, avg={self.average:.1f})\"\n", "\n", " @property\n", " def average(self) -> float:\n", " \"\"\"Mean of all scores; 0.0 if no scores recorded.\"\"\"\n", " return sum(self.scores) / len(self.scores) if self.scores else 0.0\n", "\n", " @property\n", " def letter_grade(self) -> str:\n", " \"\"\"Letter grade derived from current average.\"\"\"\n", " avg = self.average\n", " if avg >= 90:\n", " return \"A\"\n", " if avg >= 80:\n", " return \"B\"\n", " if avg >= 70:\n", " return \"C\"\n", " if avg >= 60:\n", " return \"D\"\n", " return \"F\"\n", "\n", " @classmethod\n", " def from_dict(cls, record: dict[str, object]) -> \"StudentRecord\":\n", " \"\"\"Construct a StudentRecord from a raw CSV row dict.\n", "\n", " Args:\n", " record: Dict with keys 'name', 'scores' (comma-separated string\n", " or list of floats), and 'program'.\n", "\n", " Returns:\n", " A new StudentRecord instance.\n", " \"\"\"\n", " raw_scores = record.get(\"scores\", \"\")\n", " if isinstance(raw_scores, str):\n", " scores = [float(s.strip()) for s in raw_scores.split(\",\") if s.strip()]\n", " else:\n", " scores = list(raw_scores) # type: ignore[arg-type]\n", " return cls(\n", " name=str(record.get(\"name\", \"\")),\n", " scores=scores,\n", " program=str(record.get(\"program\", \"Undeclared\")),\n", " )" ] }, { "cell_type": "code", "execution_count": null, "id": "14", "metadata": {}, "outputs": [], "source": [ "# Simulated rows as they would arrive from csv.DictReader\n", "raw_rows: list[dict[str, object]] = [\n", " {\"name\": \"Carol Osei\", \"scores\": \"91.0, 95.0, 89.0\", \"program\": \"Data Science\"},\n", " {\"name\": \"Dan Kimani\", \"scores\": \"74.0, 68.0, 80.0\", \"program\": \"Mathematics\"},\n", "]\n", "\n", "records = [StudentRecord.from_dict(row) for row in raw_rows]\n", "for r in records:\n", " print(r)" ] }, { "cell_type": "markdown", "id": "15", "metadata": {}, "source": [ "### Inheritance and abstract base classes\n", "\n", "Inheritance is often introduced as a code-reuse tool: put shared logic in a parent class. That is true, but it misses the more important use case in ML engineering: enforcing a contract.\n", "\n", "When you define an Abstract Base Class (ABC), you're saying: any class that claims to be a `BaseMetric` must implement `compute(actual_labels, predicted_labels)`. Python will refuse to instantiate a subclass that forgets to implement it. This isn't a convention or a code review comment, it's a runtime guarantee.\n", "\n", "This is how scikit-learn's `BaseEstimator` enforces that every estimator has `.fit()` and `.predict()`, and how PyTorch's `nn.Module` enforces `forward()`. You will implement the same pattern here for evaluation metrics." ] }, { "cell_type": "markdown", "id": "16", "metadata": {}, "source": [ "The inheritance chain makes the contract visible. `BaseMetric` declares what every metric must implement; concrete classes fill in the details. Python refuses to instantiate `BaseMetric` directly, you can only create `AccuracyMetric` or `F1Metric`." ] }, { "cell_type": "markdown", "id": "17", "metadata": {}, "source": [ "{fig-alt=\"Three boxes connected by arrows. Top: BaseMetric(ABC) in blue with abstractmethod compute. Bottom-left: AccuracyMetric in green. Bottom-right: F1Metric in green.\"}" ] }, { "cell_type": "code", "execution_count": null, "id": "18", "metadata": {}, "outputs": [], "source": [ "from abc import ABC, abstractmethod\n", "\n", "\n", "class BaseMetric(ABC):\n", " \"\"\"Abstract base class for all evaluation metrics.\n", "\n", " Subclasses must implement `compute`; Python will refuse to instantiate\n", " any subclass that does not.\n", " \"\"\"\n", "\n", " @abstractmethod\n", " def compute(self, actual_labels: list[int], predicted_labels: list[int]) -> float:\n", " \"\"\"Compute the metric value.\n", "\n", " Args:\n", " actual_labels: Ground-truth labels.\n", " predicted_labels: Predicted labels.\n", "\n", " Returns:\n", " Scalar metric value.\n", " \"\"\"\n", "\n", "\n", "class AccuracyMetric(BaseMetric):\n", " \"\"\"Fraction of predictions that match the ground truth.\"\"\"\n", "\n", " def compute(self, actual_labels: list[int], predicted_labels: list[int]) -> float:\n", " if not actual_labels:\n", " return 0.0\n", " correct = sum(t == p for t, p in zip(actual_labels, predicted_labels, strict=False))\n", " return correct / len(actual_labels)\n", "\n", "\n", "class F1Metric(BaseMetric):\n", " \"\"\"Binary F1 score: harmonic mean of precision and recall.\"\"\"\n", "\n", " def compute(self, actual_labels: list[int], predicted_labels: list[int]) -> float:\n", " tp = sum(t == 1 and p == 1 for t, p in zip(actual_labels, predicted_labels, strict=False))\n", " fp = sum(t == 0 and p == 1 for t, p in zip(actual_labels, predicted_labels, strict=False))\n", " fn = sum(t == 1 and p == 0 for t, p in zip(actual_labels, predicted_labels, strict=False))\n", " if tp == 0:\n", " return 0.0\n", " precision = tp / (tp + fp)\n", " recall = tp / (tp + fn)\n", " return 2 * precision * recall / (precision + recall)" ] }, { "cell_type": "code", "execution_count": null, "id": "19", "metadata": {}, "outputs": [], "source": [ "actual_labels = [1, 0, 1, 1, 0, 1, 0, 0]\n", "predicted_labels = [1, 0, 1, 0, 0, 1, 1, 0]\n", "\n", "metrics: list[BaseMetric] = [AccuracyMetric(), F1Metric()]\n", "for metric in metrics:\n", " score = metric.compute(actual_labels, predicted_labels)\n", " print(f\"{metric.__class__.__name__:<18}: {score:.4f}\")\n", "\n", "# Demonstrate the ABC contract: Python refuses to instantiate BaseMetric directly\n", "try:\n", " _ = BaseMetric() # type: ignore[abstract]\n", "except TypeError as exc:\n", " print(f\"\\nABC contract enforced: {exc}\")" ] }, { "cell_type": "markdown", "id": "20", "metadata": {}, "source": [ "
| Need | Reach for |
|---|---|
Store fields, get free __repr__ and __eq__ | @dataclass |
Computed properties (@property) | class |
Alternative constructors (@classmethod) | class |
| Enforce an interface (ABC) | class |
| Immutable, hashable record | @dataclass(frozen=True) |
@dataclass can have @property methods and @classmethod factories. Start with @dataclass when you only need structured storage; graduate to a plain class when you need the full toolkit shown in this section.CourseRecord class (not a dataclass) that stores code: str, semester: str, instructor: str, and students: list[str]. Add a @property enrolment that returns the student count, a __repr__ that prints CourseRecord(CS101 / 2024-S1), and a @classmethod from_dict(cls, row) that builds a record from a raw dict. Instantiate two records and print their enrolment counts.\n",
"@dataclass when the class is primarily a data container: a fixed set of typed fields with no complex initialisation logic. Use a plain class when you need a non-trivial __init__, inheritance, or methods that go well beyond reading and writing fields.__init__ would be nothing but self.x = x assignments, reach for @dataclass.\n",
"ExamResult dataclass with fields: student_id: str, course: str, score: float, semester: str. Add a __post_init__ that raises ValueError if score is outside 0-100. Instantiate a valid result and one that should fail, catching the error.\n",
"__post_init__, or methods beyond reading fields.TypedDict is purely a type-checker hint. At runtime, a TypedDict is still an ordinary dict: no __init__, no field validation, no overhead. Use it when you receive raw dicts from external sources and want your editor to catch missing or misspelled keys before the code runs.\n",
"try: code that might raise an exceptionexcept ExcType as e: handle a specific exceptionelse: runs only if NO exception was raised in tryfinally: always runs, even if an exception propagates (use for cleanup)except: or except Exception: hides bugs and silences keyboard interrupts.\n",
"parse_batch(rows) that returns (valid, errors): a list of successfully parsed floats and a list of error messages.\n",
"rows = ['85.0', '92', 'n/a', '-5', '78.5', '110', '63']\n",
"\n",
"valid, errors = parse_batch(rows)\n",
"# valid = [85.0, 92.0, 78.5, 63.0]\n",
"# errors = [\"'n/a' isn't a valid number\",\n",
"# \"'-5' out of range [0, 100]\",\n",
"# \"'110' out of range [0, 100]\"]\n",
"pathlib.Path is the standard for file-system work. It is cross-platform, composable with /, and carries methods for existence checks, directory creation, and reading/writing, all in one object. with open(...) as fh (context manager) so the file is closed automatically, even if an exception occurs.\n",
"open('data/file.csv') works but gives you no path-manipulation methods and is fragile on Windows vs. macOS/Linux. Use Path('data') / 'file.csv' instead.\n",
"save_experiment(result: dict, log_dir: Path) -> Path that saves a result dict as a timestamped JSON file (run_20240101_120000.json) inside log_dir, creating the directory if it doesn't exist. Return the path to the written file. Then read it back and verify the round-trip.datetime.now(tz=UTC).strftime('%Y%m%d_%H%M%S') for the timestamp and Path.mkdir(parents=True, exist_ok=True) for the directory.log_experiment(Path('runs.jsonl'), run_id='run-001', accuracy=0.901, loss=0.312)\n",
"log_experiment(Path('runs.jsonl'), run_id='run-002', accuracy=0.923, loss=0.218)\n",
"\n",
"# runs.jsonl contents:\n",
"# {\"run_id\": \"run-001\", \"accuracy\": 0.901, \"loss\": 0.312}\n",
"# {\"run_id\": \"run-002\", \"accuracy\": 0.923, \"loss\": 0.218}\n",
"Hint: JSONL (JSON Lines), one JSON object per line, is the standard format for streaming experiment logs. Use mode='a' to append.\n",
"def f(x=[]):, the list is created once and shared across all calls. Use None as the sentinel instead.is vs ==: is tests object identity (same object in memory); == tests value equality. Use == for values, is only for None.lambda inside a loop captures the variable, not its value at that moment. The loop ends, the variable has its final value, and every lambda returns the same thing.\n",
"lambda i=i: ...\n",
"__init__) is shared across all instances. Appending to it from one instance affects every other.__init__, or use field(default_factory=list) in a dataclass.\n",
"StudentRegistry class that:StudentRecord objects (from Sec. 1).add(record: StudentRecord) -> None.top_n(n: int = 3) -> list[StudentRecord] that returns the top n students by average.@classmethod from_csv(cls, path: Path) -> 'StudentRegistry' that reads a CSV file and populates the registry using StudentRecord.from_dict(), raising a DataLoadError (custom exception) if the file doesn't exist.save_report(path: Path) -> None that writes a JSON summary of all students to disk.