Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Authored by Souvik Mandal, Ph.D.

Project Leader & Instructor, Computational Behavioral Sciences, LS100, FAS, Harvard University | Linkedin ID: souvik-mandal-phd


Last updated: 2026-08-26

Open in Colab Open in GitHub Codespaces

So far you have learned to store data in variables and act on it with functions.

  • Variables hold information (strings, numbers, lists, dictionaries).

  • Functions perform actions — taking inputs, doing work, returning outputs.

This works beautifully for short scripts. But research code grows. The moment you are tracking many things — each of which has both data (a subject’s age, mass, species) and behaviour (schedule a recording, validate an entry) — keeping the data in loose dictionaries and the logic in separate floating functions starts to crack.

In this notebook we meet the tool built for exactly this problem: the Class. We will feel the pain of the dictionary-and-function approach first, then refactor it into clean, reliable classes, and finally meet decorators — the @ symbols that let us protect and supercharge our classes. Throughout, we build one running example drawn straight from this course: a small system for managing a behavioural study.

How to Efficiently Use This Notebook

  • Run every code cell yourself, top to bottom — later cells depend on classes defined earlier.

  • When you meet a new keyword (__init__, self, @property), pause and re-read the explanation before moving on.

  • Mini Challenges and Checkpoints are for you to attempt — they are where the ideas stick.

  • Notice the recurring rhythm: we state a problem, write the messy version, then refactor it properly. Learning to recognise that “this is getting fragile — time for a class” instinct is the real goal.


Section 1: The Problem — Dictionaries & Floating Functions

Our running example: a Behavioral Study Data Management System

By Week 3 of this course you will be collecting data — videos and audio of your subjects. Behind every study is a quiet bookkeeping problem: who is being studied, by whom, and which recordings exist for each subject. Let us try to build that system with only the tools you already have — dictionaries and functions — and watch where it strains.

We need to track Researchers, the Subjects they study, and the Recording Sessions they schedule.

High-level workflow

  1. A researcher registers in the system.

  2. The system creates a Researcher profile (name, email, lab phone).

  3. The researcher registers one or more subjects (the animals or people being studied).

  4. The researcher schedules video or audio recording sessions for a subject.

  5. The system stores and manages these relationships.

Notice the data is already more complex than a few strings: researchers need unique IDs, they own lists of subjects (nested data), and they take actions (scheduling) that must be validated against allowed recording types.

1.1. Setup and Logic

First we define the system’s “rules”: a list of valid recording modalities, and the functions that act on a researcher’s data. Notice these functions are floating — they belong to no particular researcher; they just operate on whatever dictionary you hand them.

1.2. Creating Our First Researcher (The Data)

Now we hand-build a researcher as a dictionary. Here we are a careful “manual data-entry clerk”: a single typo in a key ("session" instead of "sessions") will make our functions fail later. We must also remember to call uuid4() ourselves and to initialise empty lists by hand.

--- Researcher Profile Created ---
Researcher: Asha
ID assigned: b1fd6120-2404-43d1-87aa-df1101195c9d
Initial sessions: []

1.3. Executing an Action (Connecting Logic to Data)

The logic (schedule_recording) and the data (researcher_01) are separate entities. To make them work together, we must “inject” the dictionary into the function as an argument. The function then reaches inside, finds the "sessions" list, and modifies it.

--- Scheduling for Asha ---
Success: scheduled 2026-03-01 | Rex | video for Asha
Success: scheduled 2026-03-02 | Rex | audio for Asha
Success: scheduled 2026-03-03 | Rex | audio for Asha
------------------------------
Asha now has 3 sessions.
Updated Asha's phone to 555-9999.
Updated phone: 555-9999

1.4. Adding a New Researcher (The Scaling Problem)

What happens when a second researcher joins the lab? We must repeat the entire manual dictionary — and keep its structure identical. With 100 researchers we copy-paste this block 100 times. And if we ever rename "subjects" to "participants", we must hunt down and fix every single dictionary by hand.

--- Scheduling for Diego ---
Success: scheduled 2026-04-10 | Finch-03 | audio for Diego
Success: scheduled 2026-04-11 | Finch-03 | audio for Diego

Researcher 02 sessions: ['2026-04-10 | Finch-03 | audio', '2026-04-11 | Finch-03 | audio']

1.5. Why This Approach Breaks Down

Our prototype works, but it is fragile. The cracks are structural:

  1. No schema enforcement. Nothing forces every researcher to have the same keys. If researcher_01 has "phone" but researcher_02 does not, code downstream hits a KeyError.

  2. Manual redundancy. Rename one key and you must edit every dictionary and every function that touches it — an open invitation to bugs.

  3. Data inconsistency. Nothing stops "subjects" in one record and "subject" in another. Retrieval logic becomes fragile guesswork.

  4. Decoupled logic and state. The function has no intrinsic connection to the data; it only acts on whatever you explicitly pass in.

  5. High cognitive load. To write even one line, you must keep the entire nested structure — every key, every type — in your head.

And consuming the data is even harder than building it. Suppose you want “the first subject’s name and the most recent session.” You must reach into the "subjects" list, check it is not empty, grab element [0], read its "name", then reach into "sessions" and grab element [-1] — checking at every step that the key and index exist so the program does not crash.

There is a better way. We need something that bundles the data and its behaviour into one reliable unit. That is a Class.


Section 2: The Solution — Classes & Object-Oriented Design

2.1. Concept: From Functions to Objects

A Class is a formal blueprint for a kind of thing. It guarantees every object built from it starts with the right attributes, automates repetitive setup (like ID generation), and keeps the relevant logic attached as methods.

This buys us two things immediately:

  • Schema consistency — every Researcher is guaranteed to have a name, an email, a subject list.

  • Syntactic clarity — we read data with dot notation (researcher.name) instead of error-prone string keys (researcher["name"]).

Table 1 — Structural shift

ConceptFunctional approachObject-Oriented approach
Data storageloose variables / dictionariesAttributes (variables attached to an object)
Logic / actionfloating functionsMethods (functions attached to an object)
Organisationdecoupled blocksEncapsulation (one structured unit)

Table 2 — Core terminology

IdeaPython termMeaning
Blueprintclassthe definition of a type (the concept of a “Researcher”)
Instanceobjecta specific realisation (the researcher “Asha”)
Stateattributea variable inside an object (asha.email)
Behaviourmethoda function belonging to an object (asha.update_phone())

Table 3 — Modelling our study system

EntityWhat its class manages
Researcheridentity, contact info, and the subjects + sessions they own
Subjectthe studied individual’s record (name, species, age, body mass) and its owner link
RecordingSessiona single capture: date, modality, derived storage size, status
Modalitythe recording-type rules (video / audio) and storage estimates

2.2. Class Design Best Practices

Before writing a class, two conventions and one design question.

Naming (PEP 8):

  • Classes use PascalCase — every word capitalised, no underscores: Researcher, RecordingSession.

  • Attributes, methods, variables use snake_case — lowercase with underscores: body_mass_kg, update_phone().

Where should the logic live? The Single Responsibility Principle says a method belongs in the class whose data it primarily touches. update_phone() changes a researcher’s contact info, so it lives in Researcher. Categorising a subject by body mass touches the subject’s data, so get_size_category() lives in Subject.

Which class do we build first? We answer with dependencies. A Researcher can exist before registering any subject (their subject list simply starts empty). But a Subject cannot exist as an orphan — it needs an owner to be meaningful. Because Subject depends on Researcher, we define Researcher first.

2.3. Implementing the Researcher Class

Below is the standard anatomy of a Python class. Three improvements over the dictionary approach are worth naming as you read:

  • Automated setup — the researcher_id is generated automatically; no one can forget it.

  • Schema enforcement — creating a Researcher requires a name and email, so every instance is consistent.

  • Encapsulated logic — schedule_recording is now a method that validates against the object’s own subject list before changing its own session history.

2.4. Concept: Deconstructing the Class Anatomy

Several new pieces appeared above. Here is what each one does.

The module import (uuid). from uuid import uuid4 pulls a ready-made function from Python’s standard library to generate unique ID strings — so we never hand-assign IDs.

The constructor __init__. A special method Python runs automatically the moment you create an object. The double underscores make it a “dunder” (double-underscore) method, reserved by the language. Its job is to set up the object’s starting state.

The instance reference self. Think of the class as a blank form and self as the specific copy currently being filled in. When you write asha = Researcher("Asha", ...), Python passes that new object in as self, so self.name = name writes “Asha” into Asha’s memory, not anyone else’s.

Type hints & defaults. In (name: str, email: str, phone: str | None = None):

  • : str is a type hint — documentation that name should be a string.

  • str | None means the value may be a string or None.

  • = None is a default — omit phone and Python fills in None instead of crashing.

Parameters vs. Attributes (a critical distinction)

These often share a name inside __init__ but play different roles:

  • Parameters are the temporary inputs in the function signature (def __init__(self, name, ...)). They carry data in, then vanish when __init__ finishes.

  • Attributes are the permanent storage attached with self (self.name). They live for the whole life of the object and are read later with dot notation.

The single line that bridges them is self.name = name, which means: “take the value in the temporary parameter name, and store it permanently in this object’s attribute self.name.”

Core rule: parameters are temporary inputs to a function; attributes are the persistent state of an object.

2.5. Concept: Instantiation and Dot Notation

Creating an object from a class is called instantiation. Instead of hand-building a dictionary, we call the class by name and pass the initialisation arguments; Python runs __init__ and hands back a new object.

A note on how we pass arguments. Python allows positional arguments (Researcher("Asha", "asha@university.edu")), which rely on order. Professionals prefer keyword arguments (Researcher(name="Asha", email="asha@university.edu")) because they are unambiguous and typo-proof — you cannot accidentally swap two strings.

Key Characteristics:

  • Read attributes with dot notation: researcher.name, not researcher["name"].

  • Each object has its own isolated state — Asha’s name and Diego’s name live in separate memory.

  • The class now behaves like a custom data type, as predictable as int or list.

Name : Asha
Email: asha@university.edu
ID   : 497475cf-ac18-4f14-a985-c030125fd158

2.6. Calling Methods on an Object

Now we trigger the methods with dot notation. The object manages its own state and enforces its own validation — no floating functions, no data injection.

System note: if you re-run the instantiation cell, the researcher_id changes every time, because uuid4() mints a fresh string on each new object.

--- Asha: initial phone = 555-0100 ---
[Update] Asha's phone is now 555-9999

--- Scheduling sessions ---
--- Scheduling video for Rex ---
Confirmed for 2026-03-01
Confirmed for 2026-03-02
--- Scheduling audio for Finch-03 ---
Error: Asha has no registered subject 'Finch-03'. Register it first.

--- Asha's session history ---
 - 2026-03-01 | Rex | video
 - 2026-03-02 | Rex | video

Mini Challenge 2.6. Create a third researcher with your own name and email, register two subjects, schedule one video session for each, then print how many sessions they now hold.

2.7. Concept: A Dynamic Lifecycle (adding a Subject later)

Real data is rarely static. A researcher might sign up today and register a subject next week. Because we defaulted subject_names to None (and converted it to an empty list inside __init__), a researcher can safely exist with no subjects, then gain them over time.

Now we also build the Subject class itself — the studied individual. It carries its measurements and a get_size_category() method that bins it by body mass, the kind of derived label you will compute constantly in this course.

Created Mei; subjects = []
--- Scheduling video for Sparrow-12 ---
Error: Mei has no registered subject 'Sparrow-12'. Register it first.

Mei registered a subject: ['Sparrow-12']
--- Scheduling video for Sparrow-12 ---
Confirmed for 2026-07-01

Subject profile: Sparrow-12 (House sparrow), size = Small, owner = a197a0c9...

2.8. Concept: The RecordingSession Class & Object Composition

So far a “session” was just a text string like "2026-03-01 | Rex | video". A string cannot estimate its own storage size or carry a status. Let us promote it to a real RecordingSession object.

Notice the class derives its storage_gb automatically from the modality — video sessions are far larger than audio. This is encapsulated business logic: the object computes its own properties, so they are always consistent.

Then we will see object composition: instead of storing a flat string in a researcher’s sessions list, we store the entire RecordingSession object — and gain access to all its attributes through chained dot notation (researcher.sessions[0].storage_gb).

Now we compose the objects together — a researcher, a subject, and a session — and inspect them through dot notation.

--- System status for Asha ---
Registered subjects: 1 | Sessions: 1

--- Inspecting the stored session object ---
Date    : 2026-03-05
Modality: Video
Storage : 12.0 GB
Status  : Scheduled
Ref ID  : 67e3a1c4...

Subject size category: Medium

2.9. Concept: Automating Composition (passing whole objects)

Until now we linked entities with ID strings (owner_id). OOP allows something more powerful: passing the whole object as a reference. This grants instant access to the owner’s attributes and methods, and lets us automate the bookkeeping.

Three upgrades in the refactor below:

  1. Two-way linking. When we create a Subject, we pass the actual Researcher object. Inside its constructor the subject appends itself to the owner’s subject list — automatic registration.

  2. A factory method. Researcher.schedule_recording now manufactures RecordingSession objects internally and stores them, instead of appending strings.

  3. System logging. Each __init__ prints a line, creating an automatic audit trail.

[System] Created Researcher: Asha | asha@university.edu | 555-0100
[System] Created Subject: Rex (Greyhound) -> owner Asha

--- Request: Video for Rex ---
  [Session] Video on 2026-03-10 | Rex | 12.0 GB | Scheduled
  [Session] Video on 2026-03-11 | Rex | 12.0 GB | Scheduled

Asha now owns 1 subject(s) and 2 session(s).
Estimated storage for this study so far: 24.0 GB

Checkpoint 2. You have now built three linked classes and seen a subject register itself and a researcher manufacture sessions. In one sentence each, explain: what self refers to, why we store whole objects instead of ID strings, and what “encapsulation” buys us. If any is fuzzy, revisit that subsection.


Section 3: Decorators — Protecting and Supercharging Classes

3.1. Concept: What Is a Decorator?

As a system matures, we often need to modify or control a function’s behaviour without rewriting its internals. A decorator does exactly that: it “wraps” a function or method with extra behaviour. Think of it as a gatekeeper that intercepts a call, runs some logic before or after, then lets the original run.

You spot a decorator by the @ symbol sitting directly above a function, method, or class definition.

3.2. The Decorator Ecosystem

Anyone can write a decorator, so there are effectively infinitely many. They fall into three groups by where they come from:

SourceAvailabilityExamples
Built-inno import needed@property, @classmethod, @staticmethod
Standard libraryimport, no install@dataclass (from dataclasses), @lru_cache (from functools)
Third-partypip install + import@app.get(...) (FastAPI), @tool (LangChain)

A few you will actually meet:

  • @property — lets a method be read like a plain attribute (researcher.email), giving controlled, read-only access.

  • @<attr>.setter — the gatekeeper that runs whenever someone assigns to an attribute, so you can validate before saving.

  • @dataclass — auto-writes the boilerplate (__init__, a readable printout) for data-holding classes.

  • @lru_cache — remembers expensive results so repeated calls with the same inputs return instantly (handy for heavy computations on your data).

  • @tool (AI frameworks) — exposes a Python function to a large language model so an AI agent can call it. Increasingly relevant as you integrate LLMs into research workflows.

3.3. Concept: @property and @setter for Data Integrity

Right now nothing stops a typo from corrupting our data — researcher.email = 12345 would silently succeed. We can prevent this with encapsulation: store the real value in a protected attribute (by convention, a leading underscore: _email) and route every read and write through a @property getter and an @email.setter gatekeeper. The user still writes researcher.email = "..." as if it were an ordinary attribute, but our validation runs first.

--- [Validation] attempting to set email = alex@university.edu ---
  Accepted: email validated and saved.
[System] Created Researcher: Alex

[Test 1] assign an invalid string (no '@'):
--- [Validation] attempting to set email = alex_at_university.edu ---
  Rejected: 'alex_at_university.edu' is not a valid email format.

[Test 2] assign a number:
--- [Validation] attempting to set email = 123456 ---
  Rejected: email must be a string.

[Final state] Alex's email is still: alex@university.edu

3.4. Concept: Cutting Boilerplate with @dataclass

Writing a robust class involves a lot of repetitive “boilerplate” — the __init__, the self.x = x mapping, a readable printout. For classes whose main job is holding data, the standard-library @dataclass decorator writes all of that for you.

Key Characteristics:

  • Auto __init__ — it reads your type hints (name: str) and builds the constructor.

  • Readable printout — printing the object shows Researcher(name='Asha', ...) instead of a cryptic memory address.

  • Safe mutable defaults — never write dogs=[] as a default (all instances would secretly share one list). field(default_factory=list) gives each object its own fresh list.

Automatic representation:
Researcher(name='David', email='david@university.edu', phone=None, subjects=['Bolt'], sessions=[], researcher_id='ae311ba1-1847-4a8a-baa9-2ce5a07eae66')

Testing an encapsulated method:
Confirmed for 2026-08-15

Modality record: Modality(name='video', base_storage_gb=12.0, description='High-frame-rate pose capture', modality_id='68a70bca-9186-48c9-9f06-61f14506a754')

Section 4: Summary & Capstone Challenge

You have moved from flat, procedural code to a structured, object-oriented design. Thinking in entities rather than loose variables gives you a foundation that scales.

What you mastered:

  1. Blueprints & instantiation — strict Researcher / Subject classes replace fragile dictionaries, guaranteeing a consistent schema.

  2. Object composition — passing whole objects (a Subject carries its Researcher) lets RecordingSession records bind everyone together.

  3. Encapsulation — internal validation and @property protect each object’s state from bad data.

  4. Rapid prototyping — @dataclass removes boilerplate for data-holding classes.

Capstone Challenge: Recording-Lab Capacity Management

Your study runs in a physical recording lab with a fixed number of capture stations. Right now the system allows unlimited check-ins; reality has limits. Build the management layer that governs the space.

Requirements:

  1. Blueprint — define a @dataclass named RecordingLab.

  2. State — give it:

    • lab_name: str

    • max_capacity: int (e.g. 8 simultaneous capture stations)

    • checked_in_subjects: List[Subject] (use field(default_factory=list))

  3. check_in_subject(self, subject: Subject) —

    • Validation 1: if len(self.checked_in_subjects) has reached max_capacity, reject with a “Capacity reached” warning.

    • Validation 2: if the subject is already checked in, print a warning.

    • Otherwise: append the Subject and print a success message.

  4. checkout_subject(self, subject_id: str) — find the matching subject, remove it, and free up a station.

This exercises object composition, internal state validation, and modelling a real-world constraint — exactly the skills you will lean on when your own data-collection pipeline has to respect equipment and storage limits.


End of Notebook 05. You now have the full foundational toolkit — data, automation, visualization, computational thinking, and object-oriented design — ready to apply to the computer-vision, audio, and statistics modules ahead.