Python fundamentals refresher#

This chapter collects the language features used throughout the workshop. Treat it as a reference: skim what is familiar and pause where your prediction differs from Python’s result.

Use this notebook

Use Notebook 0: Python warm-up with this chapter: open it in the book or download the notebook.

A programmer reviews extremely confusing code and compares it with several increasingly absurd constructions.

Fig. 5 Readable names and small functions make code easier to check six months after it was written. “Code Quality” by Randall Munroe, licensed CC BY-NC 2.5.#

Names, values, and types#

A variable is a name referring to an object:

participant_id = "P07"
n_trials = 80
sampling_rate = 500.0
included = True
missing_value = None

Read the assignments as short statements about the study: the participant is P07, the experiment contains 80 trials, the signal was sampled at 500 Hz, this record is included, and a value is currently missing. The comments below show the role of each line:

participant_id = "P07"  # a string label, not a number to calculate with
n_trials = 80           # an integer count of trials
sampling_rate = 500.0  # a floating-point measurement
included = True        # a Boolean decision
missing_value = None   # explicitly no value has been recorded

Inspect rather than guess:

type(participant_id)   # str
type(n_trials)         # int
type(sampling_rate)   # float
type(included)        # bool

int means an integer with no decimal part. Use it for counts, indices, and identifiers that are genuinely numeric. float represents a number with a fractional part and is common for reaction times, amplitudes, probabilities, and sampling rates. A participant ID such as "07" is usually better kept as a string: converting it to 7 would lose the leading zero and might make it look like a quantity.

Python is dynamically typed: the object has a type, while a name can later refer to a different object. Changing types without a good reason usually makes research code harder to follow.

Naming habit

Prefer names that carry scientific meaning: reaction_times is more informative than x, and trial_axis is more informative than a.

Python naming conventions make code easier to scan:

participant_ids = ["P01", "P02"]  # snake_case for variables and functions
MAX_TRIALS = 120                   # capitals for a constant used throughout a script

def mean_reaction_time(values):    # verbs or actions make function names clear
    return sum(values) / len(values)

Avoid names that hide meaning or collide with Python built-ins. list, str, sum, and id already have useful meanings in Python, so names such as trial_list and participant_id are safer. Names are case-sensitive: rt_ms and RT_ms are different names, but using one style consistently prevents mistakes.

Operators and comparisons#

duration = 500 / 1000       # division: 0.5
remainder = 17 % 5          # modulo: 2
squared = 3 ** 2            # exponentiation: 9

is_fast = duration < 0.7
is_valid = duration > 0 and duration < 2

Chained comparisons are a common and natural way to write Python code:

is_valid = 0 < duration < 2

Use == to compare values and = to assign a name. Use is None when checking for the special singleton None.

Strings#

Strings are immutable sequences of characters, meaning that their contents cannot be changed in place after the string is created:

participant = "P07"
condition = "control"
label = f"{participant}_{condition}"

lower_label = label.lower()                    # return a new lower-case string
parts = label.split("_")                       # return a list: ["P07", "control"]
new_label = label.replace("control", "experimental")  # return a new string

A method such as .replace() returns a new string because it does not modify the original string in place.

print(label)  # still P07_control

Lists, tuples, sets, and dictionaries#

Choose a collection based on its meaning:

Type

Example

Use when

list

[0.31, 0.28, 0.42]

order matters and values may change

tuple

(80, 32, 500)

a fixed record or shape is useful

set

{"A", "B"}

membership or unique values matter

dict

{"id": "P07", "age": 24}

values have named keys

Lists use integer positions:

channels = ["Fz", "Cz", "Pz"]
channels[0]       # Fz
channels[-1]      # Pz
channels[1:]      # ["Cz", "Pz"]

Dictionaries use keys:

participant = {"id": "P07", "age": 24, "included": True}
participant["age"]
participant.get("group", "unknown")

Mutation and copying#

Run it here: change the value appended to alias, then run the example again.

Output will appear here.

Some methods mutate an object:

channels = ["Fz", "Cz"]
result = channels.append("Pz")

print(channels)  # ["Fz", "Cz", "Pz"]
print(result)    # None

This is a common source of confusion: .append() changes the list and returns None.

Assignment does not copy an object:

first = [1, 2]
second = first
second.append(3)
print(first)  # [1, 2, 3]

Use first.copy() for a shallow copy. Nested mutable objects may require copy.deepcopy().

Conditions#

if reaction_time <= 0:
    status = "invalid"
elif reaction_time < 0.7:
    status = "fast"
else:
    status = "slow"

Python checks the conditions from top to bottom and stops at the first true branch. The status variable is therefore one label describing the reaction time. In a real analysis, the threshold should be documented rather than appearing as an unexplained magic number.

Indentation defines the block. Four spaces is the standard.

Values such as False, None, 0, 0.0, "", and empty collections are falsy. Be explicit when zero and missing have different meanings.

Loops and enumeration#

Loop directly over values:

for channel in channels:
    print(channel)

Use enumerate when both position and value are needed:

for index, channel in enumerate(channels):
    print(index, channel)

Use zip for aligned collections:

scores = [0.81, 0.76, 0.88]

for channel, score in zip(channels, scores):
    print(channel, score)

Comprehensions#

A comprehension constructs a collection from an iterable:

channel_labels = [channel.lower() for channel in channels]
long_trials = [rt for rt in reaction_times if rt > 0.7]
squared = {number: number ** 2 for number in range(5)}

Use a normal loop if the comprehension becomes difficult to explain to yourself or to someone else.

Functions#

Functions give a transformation a name:

def milliseconds_to_seconds(milliseconds):
    """Convert a duration in milliseconds to seconds."""
    seconds = milliseconds / 1000  # convert the unit before returning
    return seconds

The docstring states the contract. The parameters are local names, and return sends a value back to the caller.

seconds = milliseconds_to_seconds(450)

The caller does not need to know how the conversion is implemented. This separation is useful in research code: one function can be tested independently, then reused for every participant or trial.

Default arguments should normally be immutable:

def select_trials(trials, condition=None):
    if condition is None:
        return trials
    return trials[trials["condition"] == condition]

Imports and namespaces#

import numpy as np
from pathlib import Path

values = np.array([1, 2, 3])
data_path = Path("data") / "trials.csv"

np is a conventional alias. np.mean and Path live in different namespaces, which makes their origin visible.

Avoid from package import *: it hides where names came from and can overwrite existing names.

Exceptions#

A traceback is a report about what went wrong. The final line names the exception and usually provides the most useful first clue.

def positive_mean(values):
    if len(values) == 0:
        raise ValueError("values must contain at least one observation")
    return sum(values) / len(values)

Raise an error when continuing would produce a misleading scientific result.

Classes in Cognitive Science code#

A class is a recipe for creating objects with related data and behaviour. You will often use classes from libraries without defining one yourself. For example, a pandas DataFrame, a Matplotlib Axes, a pathlib Path, and a scikit-learn estimator are all objects created from classes.

The class describes what an object can contain and do. An instance is one concrete object:

class Participant:
    """Store the small amount of metadata used in this example."""

    def __init__(self, participant_id, age, condition):
        self.participant_id = participant_id  # data stored on this instance
        self.age = age
        self.condition = condition

    def describe(self):
        """Return a readable summary for a log or notebook output."""
        return f"{self.participant_id}: {self.condition}, age {self.age}"


participant = Participant("P07", 24, "control")
print(participant.condition)  # attribute access retrieves stored data
print(participant.describe()) # method call uses the stored data

__init__ runs when an instance is created. self refers to the particular instance being used. self.age and self.condition are attributes, while describe is a method. Two instances share the class definition but hold different values:

p07 = Participant("P07", 24, "control")
p08 = Participant("P08", 31, "word")

print(p07.condition)  # control
print(p08.condition)  # word

In day-to-day workshop work, prefer existing library classes unless a small class makes the research object clearer. A class can be useful when a participant, recording, or experiment has several related fields and operations. A dictionary is often simpler for one-off metadata. The design question is: will this object have a stable set of data and behaviour that should travel together?

The same object pattern appears in library code:

from pathlib import Path

data_path = Path("data")              # an instance of pathlib.Path
print(data_path.exists())              # call a method on that object

from sklearn.linear_model import LogisticRegression

model = LogisticRegression(max_iter=1000)  # create an estimator instance
model.fit(X_train, y_train)                 # fit stores learned attributes on it
predictions = model.predict(X_test)         # use the fitted object for new data

You do not need to memorise the class implementation. You need to recognise that the object has a type, attributes, and methods, and know how to inspect its documentation.

A complete small example#

records = [
    {"participant": "P01", "condition": "A", "rt": 0.51, "correct": True},
    {"participant": "P01", "condition": "B", "rt": 0.73, "correct": True},
    {"participant": "P02", "condition": "A", "rt": 0.62, "correct": False},
]

def correct_reaction_times(records, condition):
    """Return RTs for correct trials in one condition."""
    return [
        record["rt"]
        for record in records
        if record["correct"] and record["condition"] == condition
    ]

condition_a = correct_reaction_times(records, "A")
mean_a = sum(condition_a) / len(condition_a)
print(f"Mean correct RT in A: {mean_a:.2f} seconds")

This short program uses lists, dictionaries, strings, Boolean expressions, a function, a comprehension, indexing, arithmetic, and formatted output. These are the same foundations later hidden inside larger scientific libraries.

Exercise 2 (Predict before running)

What do condition_a and mean_a contain? Which record is excluded, and why?

Solution to Exercise 2 (Predict before running)

condition_a is [0.51] and mean_a is 0.51. The condition B record fails the condition check; the second condition A record fails the correctness check.