Programming in Python II - Programming in Python II
Unit 1
17 min read
This subchapter explores the creation of ndarrays from Python sequences, default type casting, explicit dtype declarations, and array creation by shape or range. - Why we need this: To understand how to store homogeneous numeric data efficiently in memory. NumPy arrays form the backbone of scientific computing and deep learning inputs (tensors). - Where we need this: In any data preprocessing pipeline where raw Python lists are converted to vectors or matrices. On the exam, you need to know type upcasting and bounds to predict array shape and contents. - 1D Array (Vector):
vector = np.array([1, 2, 3]) # Shape: (3,)
Visualization:
$\begin{bmatrix} 1 & 2 & 3 \end{bmatrix}$
- **2D Array (Matrix)**:
matrix = np.array([[1, 2], [3, 4]]) # Shape: (2, 2)
Visualization:
$\begin{bmatrix} 1 & 2
3 & 4 \end{bmatrix}
\text{[True, 2]} &\to \text{[1, 2] (int32)}
\text{`[2, 3.5]`} &\to \text{`[2.0, 3.5] (float64)`}
\text{`[3.5, "four"]`} &\to \text{`['3.5', 'four'] (Unicode string)`}
$
- **Explicit Enforcing**: Enforce a type using the `dtype` parameter:
arr = np.array([1, 2, 3], dtype=np.float32) # Shape: (3,)
Visualization:
$\begin{bmatrix} 1.0 & 2.0 & 3.0 \end{bmatrix}$
Initialize arrays of a given shape (specified as a tuple) directly in memory:
- **np.zeros((2, 3))**: Fills shape with . Shape: (2, 3) (2 rows, 3 columns).
- **np.ones((3, 1))**: Fills shape with . Shape: (3, 1) (3 rows, 1 column).
- **np.full((2, 2), 7)**: Fills shape with a custom scalar. Shape: (2, 2).
- **np.empty((2, 3))**: Allocates uninitialized memory. Extremely fast, but values are random garbage currently present in memory:
Generate arrays containing sequential ranges of values:
- np.arange(1, 5, 1)**: Half-open interval with step 1. Shape: (4,).
- np.linspace(1, 5, 5)**: Generates 5 evenly spaced points in the closed interval . Shape: (5,).
What is the shape and dtype of the array returned by np.array([[False, 2], [3.0, 4]], dtype=np.int32)?
- A. Shape (2, 2) and dtype=float64
- B. Shape (2, 2) and dtype=int32
- C. Shape (4,) and dtype=int32
- D. It raises a TypeError because False cannot be cast to an integer explicitly.
\hyperlink{sol:1_1}{[View Solution & Explanation]}
\subsection{Subchapter 1.2: Indexing, Slicing, and Reshaping}
Key concepts
Unit 2
19 min read
This subchapter explores the creation of 2D line plots from sequence coordinates, line styling configurations, overlapping multiple plots on a single canvas, and adding annotations like legends and labels.
- Why we need this: Line plots are the standard method for visualizing continuous functions, trends over time, or neural network loss curves during training.
- Where we need this: On exams, you must know how default x-coordinates are generated when omitted, how line properties (colors, markers, styles) are combined, and how legends are automatically populated using labels.
If only a single 1D sequence is passed to plt.plot(y), NumPy automatically generates x-coordinates as integers: .
import matplotlib.pyplot as plt
y = np.array([10, 30, 20])
plt.plot(y)
Input Shapes: y is (3,)
Coordinate Pairs:
Actual Output Plot: [Image] Passing two 1D sequences of identical shape plots them as coordinate pairs.
x = np.array([1, 2, 4])
y = np.array([5, 15, 10])
plt.plot(x, y)
Input Shapes: x is (3,), y is (3,)
Coordinate Pairs:
Actual Output Plot:
[Image]
Customize the line appearance by specifying colors, line widths, line styles, and point markers:
- color: 'r' (red), 'b' (blue), 'g' (green), or hex codes '#FF5733'.
- linewidth (or lw): float value (e.g. linewidth=2.5).
- linestyle (or ls): '-' (solid), '--' (dashed), '-.' (dash-dot), ':' (dotted).
- marker: 'o' (circle), 's' (square), 'x' (cross), '+' (plus).
plt.plot(x, y, color='red', linewidth=2, linestyle='--', marker='o')
Input Shapes: x is (3,), y is (3,)
Actual Output Plot:
[Image]
Calling plt.plot() multiple times appends lines to the same active figure. Labels, titles, gridlines, and legends should be added to provide context.
plt.plot(x, y1, color='blue', label='Train Loss')
plt.plot(x, y2, color='orange', label='Val Loss')
plt.title('Training Performance')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend() # Displays 'Train Loss' and 'Val Loss' boxes
plt.grid(True)
Input Shapes: x, y1, y2 are all (3,)
Actual Output Plot:
[Image]
Note that in all the above code blocks, the plots are displayed to the user by calling plt.show() at the very end of the plotting command sequence.
If you run the following code block:
import matplotlib.pyplot as plt
import numpy as np
y = np.array([5, 10, 15])
plt.plot(y)
plt.plot(y + 5)
plt.show()
What coordinates are plotted for the second line?
- A. (0, 10), (1, 15), (2, 20)
- B. (5, 10), (10, 15), (15, 20)
- C. (1, 10), (2, 15), (3, 20)
- D. It raises a ValueError because the second line has no explicit x-coordinates.
\hyperlink{sol:2_1}{[View Solution & Explanation]}
\subsection{Subchapter 2.2: Subplots and Figure Layouts}
Key concepts
Unit 3
25 min read
This subchapter covers the creation of Series and DataFrames, their internal data storage mechanisms (homogeneity vs. heterogeneity, upcasting pitfalls during NumPy conversion), index management (RangeIndex, Index, reset_index, set_index), and the precise syntax and behaviors of Pandas indexing modes (df[...]``, loc, iloc, at, iat, and multi-level MultiIndex indexing). - **Why we need this**: Real-world data is tabular (spreadsheets, database tables) with named columns, mixed data types, and missing values. Pure NumPy is too low-level because it requires homogenous types and lacks column/row label support. - **Where we need this**: On exams, JKU tests details about label-based vs. integer-based slicing boundaries (specifically that locslices are inclusive of the upper boundary whileilocis exclusive), how row slicing returns a Series whose index labels are the original DataFrame's column names, and how converting mixed-type DataFrames usingdf.valuesordf.to_numpy()forces type upcasting (e.g. converting floats and integers toobjector strings if a string column is present, which wastes memory and slows down computation). **The Theoretical Definitions**: - **Series**: A 1D ordered data structure. Every element is associated with an index label. By default, labels are integers from $0$ to $n-1$, but custom labels of any type (e.g., strings) are supported. Internally, a Series stores its values in a 1D NumPy array. A Series is homogeneous (all elements share a single type), but using theobjectdtype allows for mixed Python objects. - **DataFrame**: A 2D tabular data structure (rows and columns) that can be visualized as a collection of 1D Series (columns) sharing a common row index. Unlike NumPy matrices, DataFrames are heterogeneous—each column can store a completely different data type. - **Index Uniqueness**: Unlike SQL tables, Pandas indices/labels do not have to be unique! **Memory and NumPy Conversion Pitfalls**: Internally, each column of a DataFrame is stored as a separate 1D array. When you calldf.values(or the modern preferreddf.to_numpy()), Pandas must collapse the 2D layout into a single contiguous 2D NumPy array. Because a NumPy array is strictly homogenous, Pandas must find the most general common data type that fits all values (implicit upcasting): - Columns of type int32andfloat64will be upcast to a singlefloat64NumPy array. - Columns of typefloat64andobject(strings) will be upcast to anobject` NumPy array (containing Python string objects), which is slow and memory-intensive.
import pandas as pd # Import the Pandas library under the alias pd
import numpy as np # Import the NumPy library under the alias np
# Create a 1D Series of random values with default integer index [0, 1, 2, 3]
s1 = pd.Series(np.random.rand(4))
# Create a 1D Series with custom labels 'a', 'b', 'c', 'd'
s2 = pd.Series(np.random.rand(4), index=['a', 'b', 'c', 'd'])
# Create a 2D DataFrame from a dictionary of column arrays and custom row index labels
df_labels = pd.DataFrame(
{'c1': np.random.rand(5), # Column 'c1' containing 5 random floats
'c2': np.random.rand(5), # Column 'c2' containing 5 random floats
'c3': np.random.rand(5)}, # Column 'c3' containing 5 random floats
index=['r1', 'r2', 'r3', 'r4', 'r5'] # Sets custom row labels 'r1' through 'r5'
)
# Convert DataFrame back to a 2D NumPy array (upcasts types to a common dtype)
vals_old = df_labels.values # Returns 2D NumPy array (older attribute)
vals_new = df_labels.to_numpy() # Returns 2D NumPy array (modern preferred method)
Console Output / Visualization:
>>> print(s1)
0 0.548814
1 0.715189
2 0.602763
3 0.544883
dtype: float64
>>> print(s2)
a 0.423655
b 0.645894
c 0.437587
d 0.891773
dtype: float64
>>> print(df_labels)
c1 c2 c3
r1 0.963663 0.925597 0.778157
r2 0.383442 0.071036 0.870012
r3 0.791725 0.087129 0.978618
r4 0.528895 0.020218 0.799159
r5 0.568045 0.832620 0.461479
>>> print(vals_new)
[[0.96366276 0.92559664 0.77815675]
[0.38344152 0.07103606 0.87001215]
[0.79172504 0.0871293 0.97861834]
[0.52889492 0.0202184 0.79915856]
[0.56804456 0.83261985 0.46147936]]
There are three primary options for indexing and slicing DataFrames in Pandas:
- **Chained / Default Indexing (df[...]**):
- Column Access: Passing a single string label (e.g. df['c1']) selects that column and returns it as a Series.
- Row Slice: Passing a slice of integers (e.g. df[0:2]) slices rows and returns a DataFrame (excludes the stop boundary).
- Boolean Mask: Passing a boolean vector (e.g. df[mask]) filters rows where the mask is True and returns a DataFrame.
- **Label-based Indexing (df.loc[...]**):
- Accesses rows and columns by their string/label values.
- Exam Trap: Slicing using labels (e.g. df.loc['r1':'r4']) includes the upper boundary ('r4')!
- **Integer-based Indexing (df.iloc[...]**):
- Accesses rows and columns by their integer offsets, identical to NumPy array indexing.
- Slicing using integers (e.g. df.iloc[0:4]) excludes the upper boundary (offset 4 is not included).
- Scalar Accessors (df.at[...], \texttt{df.iat[...]``)}:
- Highly optimized methods designed to look up or set a single scalar value. Much faster than loc or iloc because they bypass index overhead. at uses labels; iat uses integer offsets.
Slicing Return Type Rules:
- Select a column: Returns a Series.
- Select a single row: Returns a Series where the column names of the original DataFrame become the row index labels of the Series!
- Select a subregion: Returns a DataFrame.
- Select a single element: Returns a scalar value.
# ---- Column Access and Slices (Chained) ----
col_c3 = df_labels['c3'] # Selects column 'c3'; returns a Series
row_slice = df_labels[0:4] # Slices rows [0, 1, 2, 3]; returns a DataFrame (excludes row offset 4)
bool_slice = df_labels[[True, False, True, False, True]] # Filters rows; returns rows 0, 2, 4 as a DataFrame
# ---- Label-Based Indexing (.loc) ----
row_r1 = df_labels.loc['r1'] # Selects row 'r1'; returns a Series (where column names 'c1', 'c2', 'c3' are index labels)
row_slice_loc = df_labels.loc['r1':'r4'] # Slices rows 'r1' through 'r4' INCLUSIVE; returns a DataFrame
subregion_loc = df_labels.loc['r1':'r4', 'c1':'c2'] # Slices rows 'r1':'r4' and columns 'c1':'c2' inclusive; returns a DataFrame
# ---- Integer-Based Indexing (.iloc) ----
row_idx_0 = df_labels.iloc[0] # Selects row offset 0 (first row); returns a Series
row_slice_iloc = df_labels.iloc[0:4] # Slices rows 0 up to 4 EXCLUSIVE (offsets 0, 1, 2, 3); returns a DataFrame
subregion_iloc = df_labels.iloc[0:4, 1:3] # Slices rows [0, 1, 2, 3] and columns [1, 2]; returns a DataFrame
# ---- Optimized Scalar Lookup (.at and .iat) ----
val_at = df_labels.at['r2', 'c3'] # Highly optimized lookup for single scalar at row 'r2', col 'c3'
val_iat = df_labels.iat[1, 2] # Highly optimized lookup for single scalar at row offset 1, col offset 2
Console Output / Visualization:
>>> print(col_c3)
r1 0.778157
r2 0.870012
r3 0.978618
r4 0.799159
r5 0.461479
Name: c3, dtype: float64
>>> print(row_slice)
c1 c2 c3
r1 0.963663 0.925597 0.778157
r2 0.383442 0.071036 0.870012
r3 0.791725 0.087129 0.978618
r4 0.528895 0.020218 0.799159
>>> print(bool_slice)
c1 c2 c3
r1 0.963663 0.925597 0.778157
r3 0.791725 0.087129 0.978618
r5 0.568045 0.832620 0.461479
>>> print(row_r1)
c1 0.963663
c2 0.925597
c3 0.778157
Name: r1, dtype: float64
>>> print(row_slice_loc)
c1 c2 c3
r1 0.963663 0.925597 0.778157
r2 0.383442 0.071036 0.870012
r3 0.791725 0.087129 0.978618
r4 0.528895 0.020218 0.799159
>>> print(subregion_loc)
c1 c2
r1 0.963663 0.925597
r2 0.383442 0.071036
r3 0.791725 0.087129
r4 0.528895 0.020218
>>> print(subregion_iloc)
c2 c3
r1 0.925597 0.778157
r2 0.071036 0.870012
r3 0.087129 0.978618
r4 0.020218 0.799159
>>> print(val_at)
0.870012
DataFrames carry an index object which can be inspected or modified:
# Inspecting the index objects
idx_obj = df_labels.index # Returns the Index object containing ['r1', 'r2', 'r3', 'r4', 'r5']
# Reset the index to standard integer range [0, 1, ..., n-1]
df_reset = df_labels.reset_index() # Resets index; moves the old index labels to a new column named 'index'
df_reset_drop = df_labels.reset_index(drop=True) # Resets index; deletes the old index labels completely
# Set a column as the new index
df_new_index = df_labels.set_index('c1') # Sets column 'c1' as the new index; removes 'c1' from the columns list
Console Output / Visualization:
>>> print(idx_obj)
Index(['r1', 'r2', 'r3', 'r4', 'r5'], dtype='object')
>>> print(df_reset)
index c1 c2 c3
0 r1 0.963663 0.925597 0.778157
1 r2 0.383442 0.071036 0.870012
2 r3 0.791725 0.087129 0.978618
3 r4 0.528895 0.020218 0.799159
4 r5 0.568045 0.832620 0.461479
>>> print(df_reset_drop)
c1 c2 c3
0 0.963663 0.925597 0.778157
1 0.383442 0.071036 0.870012
2 0.791725 0.087129 0.978618
3 0.528895 0.020218 0.799159
4 0.568045 0.832620 0.461479
>>> print(df_new_index)
c2 c3
c1
0.963663 0.925597 0.778157
0.383442 0.071036 0.870012
0.791725 0.087129 0.978618
0.528895 0.020218 0.799159
0.568045 0.832620 0.461479
A DataFrame can have multiple hierarchical levels in its rows or columns:
# Nested list arrays defining index labels for 2 levels
arrays = [
['A', 'A', 'B', 'B', 'C', 'C'], # Outer index level keys
[1, 2, 1, 2, 1, 2] # Inner index level keys
]
# Create a MultiIndex object from arrays
multi_idx = pd.MultiIndex.from_arrays(arrays, names=('Letter', 'Number'))
# Create a DataFrame using the MultiIndex row headers
df_multi = pd.DataFrame(
np.random.randn(6, 4), # Random normal data matrix of shape (6, 4)
index=multi_idx, # Assign MultiIndex to the rows
columns=['C1', 'C2', 'C3', 'C4'] # Column headers
)
# Selecting from a MultiIndex DataFrame requires a coordinate tuple
sub_select = df_multi.loc[('A', 1), :] # Selects row (Letter='A', Number=1); returns columns as a Series
# Stack: pivots column labels into the innermost index level (returns a Series)
df_stacked = df_multi.stack()
# Unstack: pivots the innermost index level ('Number') into new column headers (returns a DataFrame)
df_unstacked = df_multi.unstack()
Console Output / Visualization:
>>> print(df_multi)
C1 C2 C3 C4
Letter Number
A 1 -0.374472 0.275198 -0.960755 0.376927
2 0.033439 0.680567 -1.563497 -0.566698
B 1 -0.242150 1.514391 -0.333057 0.047365
2 1.462740 1.535029 0.566440 0.149265
C 1 -1.078278 1.395472 1.787484 -0.569517
2 0.175387 -0.462506 -1.085801 0.639736
>>> print(sub_select)
C1 -0.374472
C2 0.275198
C3 -0.960755
C4 0.376927
Name: (A, 1), dtype: float64
>>> print(df_stacked.head(8))
Letter Number
A 1 C1 -0.374472
C2 0.275198
C3 -0.960755
C4 0.376927
2 C1 0.033439
C2 0.680567
C3 -1.563497
C4 -0.566698
dtype: float64
>>> print(df_unstacked)
C1 C2 C3 C4
Number 1 2 1 2 1 2 1 2
Letter
A -0.374472 0.033439 0.275198 0.680567 -0.960755 -1.563497 0.376927 -0.566698
B -0.242150 1.462740 1.514391 1.535029 -0.333057 0.566440 0.047365 0.149265
C -1.078278 0.175387 1.395472 -0.462506 1.787484 -1.085801 -0.569517 0.639736
Slide Theory Checkpoint:
- Type Upcasting on Array Conversion: If we have a DataFrame with a column of integers, a column of floats, and a column of strings, and we call to_numpy(), what is the dtype of the returned array?
- Answer: It will be object. This is because a NumPy array must have a single data type, and the only type that can accommodate strings, integers, and floats is Python's general object type.
- loc vs iloc slicing limits: What is the shape of df.loc['r1':'r3'] vs. df.iloc[0:2] on a DataFrame with index ['r1', 'r2', 'r3', 'r4']?
- Answer: df.loc['r1':'r3'] contains rows 'r1', 'r2', and 'r3' (3 rows). df.iloc[0:2] contains rows at offset 0 and 1 (2 rows), because the integer stop boundary is exclusive.
- Non-Unique Indices: Are DataFrame indexes required to have unique values?
- Answer: No. Pandas allows duplicate labels in both row indexes and column indexes. Doing lookups on non-unique indexes returns a sub-slice of multiple values (a Series or DataFrame) instead of a single scalar or row.
Let df be a DataFrame defined as:
import pandas as pd
import numpy as np
df = pd.DataFrame(
{'A': [10, 20, 30],
'B': [1.5, 2.5, 3.5]},
index=['x', 'y', 'z']
)
Console Output / Visualization:
>>> print(df)
A B
x 10 1.5
y 20 2.5
z 30 3.5
What is the type of the value returned by the expression df.loc['y'] and what are its index labels?
- A. It returns a DataFrame with index ['y'] and columns ['A', 'B'].
- B. It returns a Series with index labels ['A', 'B'].
- C. It returns a Series with index labels ['x', 'y', 'z'].
- D. It returns a 1D NumPy array with shape (2,).
Let df be a DataFrame defined as:
import pandas as pd
import numpy as np
df = pd.DataFrame(
{'A': [10, 20, 30],
'B': [1.5, 2.5, 3.5]},
index=['x', 'y', 'z']
)
What is the type of the value returned by the expression df.loc['y'] and what are its index labels?
- A. It returns a DataFrame with index ['y'] and columns ['A', 'B'].
- B. It returns a Series with index labels ['A', 'B'].
- C. It returns a Series with index labels ['x', 'y', 'z'].
- D. It returns a 1D NumPy array with shape (2,).
\hyperlink{sol:3_1}{[View Solution & Explanation]}
\subsection{Subchapter 3.2: Data Wrangling}
Key concepts
Unit 4
17 min read
This subchapter covers the fundamental building blocks of a Shiny application: input widgets that capture user data, render decorators that produce reactive outputs, and the core mechanism by which Shiny's dependency graph automatically re-executes functions when their inputs change.
- Why we need this: Scripts and Jupyter Notebooks are not distributable user-facing applications. Shiny lets you wrap your analysis into an interactive web app that non-programmers can use.
- Where we need this: On the exam, you must know the difference between Shiny Express and Core syntax, how input IDs map to input.<id>() calls, and which @render.* decorator matches which return type.
Shiny comes in two flavours:
- Shiny Express: Developed specifically for Python in 2024. Uses top-level statements and context managers (with) for layouts. Output placement is implicit --- the decorator handles both reactivity and UI rendering.
- Shiny Core: The original API carried over from R. Uses nested function calls for the UI, a separate server(input, output, session) function, and explicit output elements (e.g.\ ui.output_code("greeting")).
Key Differences (Exam-Relevant):
- Import paths differ:
# Express import path:
from shiny.express import input, render, ui # Express-specific
# Core import path:
from shiny import App, render, ui # Core; note the `App` class
- **Express**: UI and server code are mixed in one script. Containers use `with` blocks (context managers).
- **Core**: UI is defined as a nested tree (`app_ui = ui.page_fixed(...)`). Server logic lives inside `def server(input, output, session):`. The app is instantiated with `app = App(app_ui, server)`.
- **Express**: No need to define output elements --- the `@render.*` decorator implicitly places them.
- **Core**: You must explicitly define output UI elements (e.g.\ `ui.output_code("greeting")`) and the render function's **name must match** the output element's ID.
The ui module provides functions that create HTML input elements. Every input requires a unique id string, which becomes the accessor method on the input object.
Common Input Widgets:
# Text input --- user types a string
ui.input_text(id="myText", label="Enter text:", value="") # Single-line text field
# Slider --- user drags a handle to select a numeric value
ui.input_slider(id="mySlider", label="Pick a number",
min=0, max=100, value=50) # Slider from 0 to 100
# Dropdown select --- user chooses from a list
ui.input_select(id="mySelect", label="Choose option",
choices=["A", "B", "C"], selected="A") # Dropdown menu
# Checkbox --- returns True or False
ui.input_checkbox(id="myCheck", label="Enable", value=False) # Single checkbox
# Radio buttons --- user picks exactly one option from a group
ui.input_radio_buttons(id="myRadio", label="Pick one",
choices=["X", "Y", "Z"]) # Radio button group
# Numeric input --- typed numeric entry with validation
ui.input_numeric(id="myNum", label="Enter number:", value=0) # Numeric field
# File upload --- user uploads a file from their machine
ui.input_file(id="myFile", label="Upload a file") # File upload button
# Action button --- triggers an event when clicked
ui.input_action_button(id="myBtn", label="Submit") # Clickable button
Accessing Input Values (inside a reactive context):
input.myText() # Returns the current string in the text field
input.mySlider() # Returns the current numeric value of the slider
input.mySelect() # Returns the currently selected option string
input.myCheck() # Returns True or False
input.myFile() # Returns a list of dicts: name, type, size, datapath
Exam Trap: input.myFile() returns a list of dictionaries (one per uploaded file). To read the file contents, access the temporary path via input.myFile()[0]["datapath"].
Render decorators turn a function into a reactive output. The decorated function re-executes automatically whenever any input.* value it reads changes (invalidates).
# @render.text --- function must return a string
@render.text # Reactive text output
def display_text():
return f"You entered: {input.myText()}" # Re-runs when myText changes
# @render.plot --- function must return/produce a matplotlib figure
@render.plot # Reactive plot output
def display_plot():
plt.hist(data, bins=30) # Shiny captures the current figure
# @render.data_frame --- function must return a pandas DataFrame
@render.data_frame # Reactive interactive table
def display_table():
return df # Renders interactive DataGrid in the browser
# @render.code --- function returns a string shown as monospace code
@render.code # Reactive code block output
def display_code():
return f"Result = {result()}"
# @render.ui --- function returns a Shiny UI element (dynamic UI)
@render.ui # Reactive UI element generator
def dynamic_controls():
return ui.input_slider("n", "N", min=1, max=1000, value=500)
from shiny.express import input, render, ui # Import Express modules
ui.input_slider(id="val", label="Slider label",
min=0, max=100, value=50) # Create a slider widget
ui.input_text(id="myText", label="Enter some text:",
value="Hello Shiny!") # Create a text input
ui.input_select(id="mySelect", label="Choose an option",
choices=["Option A", "Option B", "Option C"],
selected="Option A") # Create a dropdown
@render.text # Decorator: reactive text output
def slider_val():
return f"Slider value: {input.val()}" # Re-runs on slider change
@render.text
def text_val():
return f"You entered: {input.myText()}" # Re-runs on text change
@render.text
def select_val():
return f"Selected: {input.mySelect()}" # Re-runs on selection change
Running App Screenshot (the web app rendered in the browser):
[Image]
How reactivity works step-by-step:
- The slider widget updates input.val(), marking it as invalidated.
- Shiny's dependency graph detects that slider_val() reads input.val().
- slider_val() is automatically re-executed.
- The new return value replaces the old text on the page.
This is reactive programming: no manual event listeners, callbacks, or polling --- the framework manages the dependency graph automatically.
from shiny import App, reactive, render, ui # Core imports (note: App class)
from datetime import datetime # For timestamps
# UI: a nested tree of function calls
app_ui = ui.page_fixed( # Fixed-width page container
ui.h1("Time Teller"), # HTML <h1> heading
ui.output_code("greeting") # EXPLICIT output placeholder (id="greeting")
)
# Server: contains all reactive logic
def server(input, output, session): # Receives input, output, session
@reactive.calc # Cached reactive calculation
def time():
reactive.invalidate_later(1) # Force re-eval every 1 second
return datetime.now().strftime("
@render.code # Name MUST match output id "greeting"
def greeting():
return f"Hi, it's currently {time()}." # Reads reactive calc
app = App(app_ui, server) # Instantiates the Shiny app object
Core vs.\ Express Key Rule: In Core, the render function's name must exactly match the id passed to the corresponding ui.output_*() element. In Express, this matching is implicit.
In Shiny Express, a developer writes the following code:
from shiny.express import input, render, ui
ui.input_slider(id="n", label="Count", min=1, max=100, value=10)
@render.text
def show_count():
return f"Count is: {input.n()}"
Which of the following statements is true?
- A. The function show_count() must be explicitly registered with ui.output_text("show_count").
- B. The function show_count() will re-execute automatically whenever the slider value changes, because reading input.n() establishes a reactive dependency.
- C. input.n (without parentheses) returns the current slider value.
- D. @render.plot would also work here, since both accept strings.
\hyperlink{sol:4_1}{[View Solution & Explanation]}
\subsection{Subchapter 4.2: Reactivity --- Events, Effects, Calculations, and Values}
Key concepts
Unit 5
25 min read
This subchapter covers the PyTorch tensor, the fundamental data structure analogous to NumPy's ndarray but with added GPU and autograd support. Tensor creation, properties, NumPy interop, device management, and operations are covered.
- Why we need this: PyTorch tensors are the basic building blocks of every neural network --- model weights, input data, and output predictions are all tensors. Understanding how to create, move, and manipulate them is prerequisite to everything else.
- Where we need this: On the exam, you must know how to create tensors, convert between NumPy and PyTorch (and the shared-memory trap!), move tensors to GPU with .to(device), and perform operations like unsqueeze/squeeze/reshape.
Tensors can be created from Python lists, NumPy arrays, or convenience functions:
import torch
import numpy as np
# From Python data
x = torch.tensor([1.0, 2.0, 3.0]) # From list
x = torch.tensor([[1, 2], [3, 4]]) # From nested list (2D tensor)
x = torch.tensor([[1, 2], [3, 4]], dtype=torch.float32) # Explicit dtype
x = torch.tensor(3.14) # Scalar tensor (0-D)
x = torch.tensor(np.array([[1, 2], [3, 4]])) # From NumPy array
Convenience Functions (analogous to NumPy):
shape = (2, 3)
torch.zeros(shape) # All zeros: tensor([[0., 0., 0.], [0., 0., 0.]])
torch.ones(shape) # All ones: tensor([[1., 1., 1.], [1., 1., 1.]])
torch.empty(shape) # Uninitialized memory (NOT zeros!)
torch.arange(0, 10, 2) # Range: tensor([0, 2, 4, 6, 8])
torch.linspace(0, 1, 5)# Evenly spaced: tensor([0.00, 0.25, 0.50, 0.75, 1.00])
torch.rand(shape) # Uniform random [0, 1)
torch.randn(shape) # Normal distribution (mean=0, std=1)
torch.randint(0, 10, (2, 3)) # Random integers in [0, 10)
***_like** Functions --- create tensors with the same shape as an existing tensor:
original = torch.tensor([[1, 2], [3, 4]])
torch.ones_like(original) # tensor([[1, 1], [1, 1]]) --- same shape
torch.zeros_like(original) # tensor([[0, 0], [0, 0]])
torch.rand_like(original, dtype=torch.float32) # Override dtype
t = torch.rand(3, 4) # Random 3x4 tensor
t.shape # torch.Size([3, 4]) --- dimensions
t.dtype # torch.float32 --- data type
t.device # device(type='cpu') --- where the tensor lives
t.ndim # 2 --- number of dimensions
t.numel() # 12 --- total number of elements (3 * 4)
Console Output:
Shape: torch.Size([3, 4])
Dtype: torch.float32
Device: cpu
ndim: 2
numel: 12
import numpy as np
np_arr = np.array([1.0, 2.0, 3.0])
t = torch.from_numpy(np_arr) # NumPy -> Tensor (SHARED memory!)
back = t.numpy() # Tensor -> NumPy (SHARED memory!)
# DANGER: modifying one changes the other!
np_arr[0] = 99.0
print(np_arr) # [99. 2. 3.]
print(t) # tensor([99., 2., 3.]) --- ALSO changed!
Console Output:
NumPy -> Tensor: tensor([1., 2., 3.], dtype=torch.float64)
After modifying NumPy arr[0]=99:
NumPy: [99. 2. 3.]
Tensor: tensor([99., 2., 3.], dtype=torch.float64) (SHARED memory!)
Exam Trap: torch.from_numpy() and .numpy() create shared-memory views. Modifying one changes the other! Use torch.tensor(np_arr) (which copies) if you need independence.
PyTorch can move tensors to GPU (VRAM) for massive parallelism:
# Check GPU availability
torch.cuda.is_available() # True if NVIDIA GPU + CUDA is available
# Standard pattern: choose device dynamically
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Move tensor to device
t_cpu = torch.tensor([1.0, 2.0, 3.0])
t_gpu = t_cpu.to(device) # Moves to GPU if available, else stays on CPU
# Create tensor directly on device
t = torch.rand(3, 3, device=device) # Created on GPU directly
# Move model to device (same syntax)
model = model.to(device) # Moves all model parameters to GPU
Key Rules:
- All tensors in an operation must be on the same device. Mixing CPU and GPU tensors raises a RuntimeError.
- .to(device) returns a new tensor (does not modify in-place for tensors; but for modules, it modifies in-place).
- GPU tensors cannot be converted to NumPy directly --- use t.cpu().numpy() first.
a = torch.tensor([1.0, 2.0, 3.0])
b = torch.tensor([4.0, 5.0, 6.0])
a + b # Element-wise addition: tensor([5., 7., 9.])
a * b # Element-wise multiply: tensor([ 4., 10., 18.])
a @ b # Dot product / matmul: tensor(32.)
m = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
m.T # Transpose
m.reshape(1, 4) # Reshape: tensor([[1., 2., 3., 4.]])
m.flatten() # Flatten to 1D: tensor([1., 2., 3., 4.])
Console Output:
a + b = tensor([5., 7., 9.])
a * b = tensor([ 4., 10., 18.])
a @ b (dot) = 32.0
m.reshape(1, 4): tensor([[1., 2., 3., 4.]])
m.flatten(): tensor([1., 2., 3., 4.])
t = torch.tensor([1.0, 2.0, 3.0]) # Shape: (3,)
# unsqueeze: adds a dimension of size 1 at the given position
t.unsqueeze(0) # Shape: (1, 3) --- adds row dimension
t.unsqueeze(1) # Shape: (3, 1) --- adds column dimension
# squeeze: removes dimensions of size 1
t.unsqueeze(0).squeeze(0) # Back to shape (3,)
Console Output:
Original shape: torch.Size([3])
unsqueeze(0) shape: torch.Size([1, 3]) -> tensor([[1., 2., 3.]])
unsqueeze(1) shape: torch.Size([3, 1]) -> tensor([[1.], [2.], [3.]])
squeeze(0) shape: torch.Size([3]) -> tensor([1., 2., 3.])
In-Place Operations --- trailing underscore _ modifies the tensor directly:
t = torch.tensor([1.0, 2.0, 3.0])
t.add_(5) # In-place! t is now tensor([6., 7., 8.])
# Regular version: t.add(5) returns new tensor, t is unchanged
Exam Trap: In-place operations (e.g.\ .add_(), .mul_(), .zero_()) are indicated by a trailing underscore. They can cause issues with autograd if used on tensors that require gradients.
A developer writes:
np_arr = np.array([10.0, 20.0, 30.0])
t = torch.from_numpy(np_arr)
np_arr[0] = 999.0
print(t[0].item())
What is printed?
- A. 10.0 --- torch.from_numpy copies the data.
- B. 999.0 --- torch.from_numpy shares memory, so the tensor reflects the change.
- C. A RuntimeError because NumPy arrays cannot be converted to tensors.
- D. 0.0 --- modifying the NumPy array resets the tensor to zeros.
\hyperlink{sol:5_1}{[View Solution & Explanation]}
\subsection{Subchapter 5.2: Autograd --- Computational Graphs and Backpropagation}
Key concepts
Unit 6
15 min read
This subchapter covers model evaluation, specifically tracking validation performance and calculating exact accuracy metrics for both binary and multi-class classification models in PyTorch.
- Why we need this: Calculating the loss function is only a part of model assessment. Loss does not represent human-interpretable performance. Metrics like classification accuracy, precision, and recall are necessary to evaluate whether a model is ready for real-world deployment.
- Where we need this: Inside any evaluation loop (validation or testing), you must calculate aggregate metrics across the entire dataset. On the JKU exam, you are heavily tested on how to use operators like torch.argmax or thresholding techniques to convert raw model outputs (logits) into correct predictions.
In binary classification, PyTorch models typically output a single raw logit per sample.
- Sigmoid Probability: Convert raw logits to probability values in via .
- Thresholding: A sample is predicted as Class 1 if (or directly if raw logit ); otherwise Class 0.
import torch
# Raw outputs (logits) for 5 validation samples
logits_bin = torch.tensor([-1.2, 0.5, 2.3, -0.8, 1.1])
targets_bin = torch.tensor([0.0, 1.0, 1.0, 0.0, 0.0]) # True classes
# Convert to probabilities via Sigmoid, then threshold at 0.5
probs_bin = torch.sigmoid(logits_bin)
preds_bin = (probs_bin >= 0.5).float()
print("Logits: ", logits_bin.tolist())
print("Probabilities:", [round(p, 4) for p in probs_bin.tolist()])
print("Predictions: ", preds_bin.tolist())
print("True Targets: ", targets_bin.tolist())
# Calculate binary accuracy
correct_bin = (preds_bin == targets_bin).sum().item()
accuracy_bin = correct_bin / len(targets_bin)
print(f"Binary Accuracy: {accuracy_bin:.4f} (Correct: {correct_bin}/{len(targets_bin)})")
Console Output:
Logits: [-1.2000000476837158, 0.5, 2.299999952316284, -0.800000011920929, 1.100000023841858]
Probabilities: [0.2315, 0.6225, 0.9089, 0.31, 0.7503]
Predictions: [0.0, 1.0, 1.0, 0.0, 1.0]
True Targets: [0.0, 1.0, 1.0, 0.0, 0.0]
Binary Accuracy: 0.8000 (Correct: 4/5)
In multi-class classification, PyTorch models output a 2D tensor of raw logits of shape (batch_size, num_classes).
- Prediction Extraction: We extract the prediction by taking the index of the highest logit along the class dimension (axis 1) using torch.argmax(logits, dim=1).
- Accuracy Calculation: Compare predicted indices with true class indices, sum matching rows, and divide by the batch size.
# Raw outputs (logits) for 3 samples across 4 classes
logits_multi = torch.tensor([
[1.5, 0.2, -0.5, 3.1], # Class 3 has highest logit (3.1)
[-0.1, 2.4, 0.8, 0.1], # Class 1 has highest logit (2.4)
[0.5, 0.5, 1.2, 0.2] # Class 2 has highest logit (1.2)
])
targets_multi = torch.tensor([3, 1, 0]) # True class indices
# Get predictions using argmax along dimension 1 (columns)
preds_multi = torch.argmax(logits_multi, dim=1)
print("Predicted Classes:", preds_multi.tolist())
print("True Targets: ", targets_multi.tolist())
# Calculate multi-class accuracy
correct_multi = (preds_multi == targets_multi).sum().item()
accuracy_multi = correct_multi / len(targets_multi)
print(f"Multi-Class Accuracy: {accuracy_multi:.4f} (Correct: {correct_multi}/{len(targets_multi)})")
Console Output:
Predicted Classes: [3, 1, 2]
True Targets: [3, 1, 0]
Multi-Class Accuracy: 0.6667 (Correct: 2/3)
When iterating over batches inside the evaluation loop, aggregate counts rather than averaging batch-wise accuracy values directly (which is mathematically incorrect if batches have different sizes).
model.eval()
val_loss = 0.0
correct_samples = 0
total_samples = 0
with torch.no_grad():
for batch_X, batch_y in val_dataloader:
outputs = model(batch_X)
loss = criterion(outputs, batch_y)
# Track cumulative loss
val_loss += loss.item() * len(batch_y)
# Calculate correct predictions in batch
preds = torch.argmax(outputs, dim=1)
correct_samples += (preds == batch_y).sum().item()
total_samples += len(batch_y)
final_val_loss = val_loss / total_samples
final_val_accuracy = correct_samples / total_samples
Exam Note: Always call .item() or convert tensors to standard Python scalars before accumulating metric values (e.g. correct_samples += ...sum().item()). Accumulating raw PyTorch tensors creates references inside the dynamic memory graph, causing massive GPU memory bloat (memory leak).
A multi-class classification model outputs raw logits for a batch of 3 samples across 3 target classes:
logits = torch.tensor([
[-0.5, 1.2, 0.2],
[ 2.1, -0.4, 0.5],
[ 0.1, 0.2, 0.3]
])
targets = torch.tensor([1, 2, 2])
What is the accuracy computed on this batch?
- A. 0.0 (All predictions are incorrect).
- B. 0.3333 (Only the first prediction is correct).
- C. 0.6667 (The first and third predictions are correct).
- D. 1.0 (All predictions are correct).
\hyperlink{sol:6_1}{[View Solution & Explanation]}
\subsection{Subchapter 6.2: Transfer Learning and Pre-trained Models}
Key concepts
Unit 7
11 min read
This appendix provides the answers, detailed solutions, and theoretical explanations for all subchapter verification questions and unit-end quizzes. - \hypertarget{sol:1_1}{Subchapter 1.1 Solution}: Correct Answer: B
The explicit parameter `dtype=np.int32` overrides the default implicit upcasting rules. Even though the elements are mixed types (`bool`, `int`, `float`, `int`), NumPy will force-cast all elements to `int32` integers, converting `False` to `0` and `3.0` to `3`. The nested lists represent a 2D grid, resulting in a shape of `(2, 2)`.
\hyperlink{q:1_1}{[Back to Question]}
- \hypertarget{sol:1_2}{**Subchapter 1.2 Solution**}: **Correct Answer: B**
`arr[:, 1]` uses proper 2D slicing where the colon `:` selects all rows, and the `1` selects the column at index 1. This returns `[2, 5, 8]` as a 1D view. (Chained indexing `arr[:][1]` first slices the whole array and then returns row index 1: `[4, 5, 6]`).
\hyperlink{q:1_2}{[Back to Question]}
- \hypertarget{sol:1_3}{**Subchapter 1.3 Solution**}: **Correct Answer: D**
To compare shape `(3,)` with `(3, 1, 5)`, NumPy prepends dimensions of size 1 to the smaller shape, transforming `(3,)` to `(1, 1, 3)`. Comparing right-to-left: Axis 2 has `5` vs `3`, which is incompatible. Option B (`(3, 4, 1)`) works since shapes match or are 1 (`5` vs `1`, `1` vs `4`, `3` vs `3`).
\hyperlink{q:1_3}{[Back to Question]}
- \hypertarget{sol:1_4}{**Subchapter 1.4 Solution**}: **Correct Answer: A**
`matrix.argmax()` searches the flattened array `[5, 15, 25, 10]`. The maximum value is `25`, which is at flat index `2`. `np.unravel_index(2, shape=(2, 2))` converts the flat index `2` back into a coordinate pair. Since index `2` corresponds to row 1, column 0, it returns the tuple `(1, 0)`.
\hyperlink{q:1_4}{[Back to Question]}
- \hypertarget{sol:1_5}{**Subchapter 1.5 Solution**}: **Correct Answer: C**
`b = np.asarray(a)` reuses the memory buffer of `a` because `a` is already a NumPy array of a matching type. Therefore, modifying `a[0]` also changes `b[0]` to `99`. `c = np.array(a)` always creates a full, independent copy in memory. Modifying `a[0]` does not affect `c`, so `c[0]` remains `1`.
\hyperlink{q:1_5}{[Back to Question]}
\hypertarget{sol:unit1_quiz}{NumPy End Quiz Solutions}:
- Question 1: Correct Answer: B (Shape (2, 2), dtype=int32). Explicit dtype forces boolean False 0 and float 3.0 3.
- Question 2: Correct Answer: B (arr[:, 1]). Slicing with colon : extracts column index 1.
- Question 3: Correct Answer: D ((3,)). Prepended dimensions align to (1, 1, 3). The trailing axis has length 5 vs 3, which is incompatible.
- Question 4: Correct Answer: A ((1, 0)). The max value 25 is at flat index 2, which unravels to coordinate (1, 0) for a grid.
\hyperlink{q:unit1_quiz}{[Back to Quiz]}
- \hypertarget{sol:2_1}{Subchapter 2.1 Solution}: Correct Answer: A
The second line plots `y + 5`, which is `[10, 15, 20]`. Since no explicit x-coordinates are passed to `plt.plot()`, Matplotlib auto-generates them as integer positions starting from 0: `[0, 1, 2]`. Thus, the plotted coordinates are `(0, 10), (1, 15), (2, 20)`.
\hyperlink{q:2_1}{[Back to Question]}
- \hypertarget{sol:2_2}{**Subchapter 2.2 Solution**}: **Correct Answer: C**
Creating subplots with `nrows=1, ncols=2` returns `ax` as a 1D NumPy array of shape `(2,)`. Because the array is 1D, it can only be indexed with a single index. Slicing with 2D coordinates (e.g. `ax[0, 1]`) raises an `IndexError`. Slicing with `ax[1]` correctly targets the second (right-hand) subplot.
\hyperlink{q:2_2}{[Back to Question]}
- \hypertarget{sol:2_3}{**Subchapter 2.3 Solution**}: **Correct Answer: B**
The variable `bins` contains the boundary edges of the histogram. For $N$ bins (here, 10), there are always $N+1$ boundaries (here, 11). Thus, it is a NumPy array of length 11. (Option A describes `n`, and Option C describes `patches`).
\hyperlink{q:2_3}{[Back to Question]}
- \hypertarget{sol:2_4}{**Subchapter 2.4 Solution**}: **Correct Answer: D**
In Matplotlib's `ax.imshow()`, custom colormap normalization limits are specified using the parameters `vmin` and `vmax`. Values below `vmin` are mapped to the lowest color; values above `vmax` are mapped to the highest color.
\hyperlink{q:2_4}{[Back to Question]}
- \hypertarget{sol:2_5}{**Subchapter 2.5 Solution**}: **Correct Answer: B**
In `ax.annotate()`, the target coordinates where the arrow points are specified by `xy`, and the placement coordinates of the text label are specified by `xytext`. Option A swaps them, while Options C and D use incorrect parameter names.
\hyperlink{q:2_5}{[Back to Question]}
\hypertarget{sol:unit2_quiz}{Matplotlib End Quiz Solutions}:
- Question 1: Correct Answer: B (Red color, dashed line style, and circle markers).
- Question 2: Correct Answer: C (ax[1].plot(y)). Since the grid is , ax is a 1D array of shape (2,). Slicing with 2D coords like ax[0, 1] raises an IndexError.
- Question 3: Correct Answer: B (It is a NumPy array of length 11 containing the boundary edge values of the bins).
- Question 4: Correct Answer: D (ax.imshow(img, cmap="viridis", vmin=50, vmax=200)).
\hyperlink{q:unit2_quiz}{[Back to Quiz]}
- \hypertarget{sol:3_1}{Subchapter 3.1 Solution}: Correct Answer: B
In Pandas, accessing a single row of a DataFrame (e.g. `df.loc['y']`) returns a 1D `Series` representation of that row. The column names of the original DataFrame (`['A', 'B']`) become the index labels of the returned Series.
\hyperlink{q:3_1}{[Back to Question]}
- \hypertarget{sol:3_2}{**Subchapter 3.2 Solution**}: **Correct Answer: C**
Option C uses `.loc` to select rows and column in a single unified step, directly modifying the original DataFrame's memory buffer. Options A and B represent chained assignments, modifying a temporary copy and raising a `SettingWithCopyWarning` without updating the original DataFrame. Option D fails with a `ValueError` or `IndexError` because `.iloc` is strictly integer-offset based and does not support label-based column strings like `'species'`.
\hyperlink{q:3_2}{[Back to Question]}
- \hypertarget{sol:3_3}{**Subchapter 3.3 Solution**}: **Correct Answer: D**
Option C (`df['col'] - df['col'].mean()`) is fully vectorized (broadcasting the scalar mean subtraction across the Series) and represents the most direct and fastest method. Option B also works but introduces Python function overhead via the lambda. Option A performs row-by-row apply which is slow and completely unnecessary.
\hyperlink{q:3_3}{[Back to Question]}
- \hypertarget{sol:3_4}{**Subchapter 3.4 Solution**}: **Correct Answer: C**
Concatenating along columns (`axis=1`) aligns rows horizontally by their index labels. Since they are disjoint, the rows cannot align, resulting in a row index that is the union of the two datasets (length 10). The column size is the sum of both DataFrames' columns (length 6). Thus, the shape is $10 \times 6$, containing the original values in diagonal blocks, with the rest filled with `NaN`.
\hyperlink{q:3_4}{[Back to Question]}
\hypertarget{sol:unit3_quiz}{Pandas End Quiz Solutions}:
- Question 1: Correct Answer: Series. Selecting a single row returns a Series indexed by the DataFrame's column names.
- Question 2: Correct Answer: iloc integer-only constraint. .iloc is strictly integer-offset based and fails when passed a column label string like 'name' or a boolean Series like df['age'] > 30.
- Question 3: Correct Answer: Index/Column alignment and NaN padding. Pandas performs arithmetic operations by aligning row/column labels, taking their union, and inserting NaN for any non-overlapping labels.
- Question 4: **Correct Answer: Full Outer Join (how='outer'**). This join type returns all records from both DataFrames, using NaN for any unmatched fields.
\hyperlink{q:unit3_quiz}{[Back to Quiz]}
- \hypertarget{sol:4_1}{Subchapter 4.1 Solution}: Correct Answer: B
In Shiny Express, `@render.text` implicitly registers the output. Reading `input.n()` inside the function establishes a reactive dependency, so it automatically re-executes when the slider changes. Option A describes Core syntax. Option C: `input.n` without parentheses is a reactive reference, not the value. Option D: `@render.plot` expects a matplotlib Figure.
\hyperlink{q:4_1}{[Back to Question]}
- \hypertarget{sol:4_2}{**Subchapter 4.2 Solution**}: **Correct Answer: B**
`@render.text` + `@reactive.event(input.myButton)` restricts reactivity to button clicks. The render decorator must be outermost. Option C uses `@reactive.effect` which has no return value. Option D reverses decorator order.
\hyperlink{q:4_2}{[Back to Question]}
- \hypertarget{sol:4_3}{**Subchapter 4.3 Solution**}: **Correct Answer: B**
The condition in `ui.panel_conditional()` is a **JavaScript expression** evaluated in the browser. This is why `&&` is used instead of Python's `and`, and `input.show` has no parentheses (it's a JS property).
\hyperlink{q:4_3}{[Back to Question]}
\hypertarget{sol:unit4_quiz}{Shiny End Quiz Solutions}:
- Question 1: The render function's name must exactly match the id of the output element (e.g.\ ui.output_code("greeting") def greeting():).
- Question 2: @reactive.calc has a return value consumers can call; @reactive.effect runs for side effects only (no return value).
- Question 3: In-place mutations don't trigger reactivity. Use .set(values_list() + [x]) to create a new list.
- Question 4: input.f()[0]["datapath"] --- returns list of dicts; datapath key has the temp file path.
\hyperlink{q:unit4_quiz}{[Back to Quiz]}
- \hypertarget{sol:5_1}{Subchapter 5.1 Solution}: Correct Answer: B
`torch.from_numpy()` creates a tensor that **shares memory** with the original NumPy array. When `np_arr[0]` is changed to `999.0`, the tensor `t` also reflects this change because they point to the same underlying data. `t[0].item()` returns `999.0`. To avoid this, use `torch.tensor(np_arr)` which creates a copy.
\hyperlink{q:5_1}{[Back to Question]}
- \hypertarget{sol:5_2}{**Subchapter 5.2 Solution**}: **Correct Answer: C**
Because `loss2` is computed inside the `with torch.no_grad():` block, PyTorch does not track its operations, and its `requires_grad` attribute is `False`. Calling `.backward()` on a tensor that does not require gradients raises a `RuntimeError: element 0 of tensors does not require grad and does not have a \texttt{grad_fn`}. (If we had wanted to update a tensor without tracking gradients, we would do so on the leaf node using in-place operations, but attempting to perform backpropagation through a non-tracked output causes a runtime failure).
\hyperlink{q:5_2}{[Back to Question]}
- \hypertarget{sol:5_3}{**Subchapter 5.3 Solution**}: **Correct Answer: D**
In PyTorch, a map-style custom dataset **must** override both `__getitem__()` (to allow sample fetching by index) and `__len__()` (so the `DataLoader` knows when to stop querying). If either is missing, using the dataset with a `DataLoader` or calling `len(dataset)` will raise a `NotImplementedError` or a `TypeError`. Implementing `__next__()` is typical for Python Iterators, not map-style Datasets. Options B and C are perfectly valid: `__getitem__()` can return any custom Python structure (tuple, dictionary, list, or a single tensor).
\hyperlink{q:5_3}{[Back to Question]}
- \hypertarget{sol:5_4}{**Subchapter 5.4 Solution**}: **Correct Answer: C**
When subclassing `nn.Module`, you **must** call `super().__init__()` as the very first line of your constructor. If omitted, PyTorch's attribute-intercept mechanism (which runs behind the scenes to register child layers in `_modules`) will trigger an `AttributeError: cannot assign module before Module.__init__() call` as soon as you attempt to assign any layer attribute like `self.fc = nn.Linear(...)`.
\hyperlink{q:5_4}{[Back to Question]}
- \hypertarget{sol:5_5}{**Subchapter 5.5 Solution**}: **Correct Answer: B**
The standard optimization step-by-step order in PyTorch is:
- `optimizer.zero_grad()`: Reset gradients from previous batch.
- `outputs = model(inputs)`: Forward pass.
- `loss = criterion(outputs, targets)`: Loss calculation.
- `loss.backward()`: Compute gradients (dLoss/dW).
- `optimizer.step()`: Update weights (W = W - lr * dLoss/dW).
Running them in any other order will result in either updating parameters with un-cleared gradients from other batches, using uncalculated gradients, or updating weights before backpropagation, causing optimization failure.
\hyperlink{q:5_5}{[Back to Question]}
- \hypertarget{sol:5_6}{**Subchapter 5.6 Solution**}: **Correct Answer: B**
The scenario describes classic overfitting: the model memorizes training patterns and fails to generalize to unseen validation data. The appropriate response is to increase regularization. **Dropout** randomly zeroes neurons during training to prevent co-adaptation, and **weight decay** (L2 regularization) penalizes large weights. Option A is incorrect because the problem is not a local minimum but overfitting. Option C is dangerous: training longer will only worsen overfitting. Option D would exacerbate the problem by removing the very mechanisms that constrain model complexity.
\hyperlink{q:5_6}{[Back to Question]}
- \hypertarget{sol:6_1}{**Subchapter 6.1 Solution**}: **Correct Answer: C**
Let's extract predictions using `torch.argmax(logits, dim=1)` for each sample:
- Sample 0 logits: `[-0.5, 1.2, 0.2]` $\to$ max value is `1.2` at class index `1`. Target is `1` (Correct!).
- Sample 1 logits: `[2.1, -0.4, 0.5]` $\to$ max value is `2.1` at class index `0`. Target is `2` (Incorrect, predicted index `0`).
- Sample 2 logits: `[0.1, 0.2, 0.3]` $\to$ max value is `0.3` at class index `2`. Target is `2` (Correct!).
Since samples 0 and 2 are predicted correctly, there are 2 correct predictions out of 3 total. The accuracy is $2/3 \approx 0.6667$.
\hyperlink{q:6_1}{[Back to Question]}
- \hypertarget{sol:6_2}{**Subchapter 6.2 Solution**}: **Correct Answer: C**
PyTorch's optimizers do not raise an error if they are passed parameters with `requires_grad=False`. However, passing frozen backbone parameters to the optimizer is extremely inefficient: the optimizer still allocates internal states (such as momentum buffers in SGD or running moments in Adam) for these frozen weights, wasting a large amount of GPU memory (VRAM) and performing unnecessary no-op mathematical calculations on gradients of zero. The correct and standard approach is to filter the parameters list before passing them to the optimizer:
optimizer = torch.optim.SGD(
[p for p in model.parameters() if p.requires_grad],
lr=0.01
)
Option D is incorrect because `model.fc` was assigned *after* the freezing loop in Step 1, so its parameters are newly created with `requires_grad=True` by default.
\hyperlink{q:6_2}{[Back to Question]}
- \hypertarget{sol:6_3}{**Subchapter 6.3 Solution**}: **Correct Answer: B**
PyTorch models and layers (such as `nn.Linear` or `nn.Conv2d`) are mathematically structured to receive and process batches of samples. Feeding a 1D tensor representing a single sample (shape `[4]`) directly to the model causes a dimension shape mismatch, raising a `RuntimeError`. To resolve this, you must prepend a batch dimension of size 1 using `sample = sample.unsqueeze(0)` (transforming the shape to `[1, 4]`). Option C is a common pitfall but incorrect: forgetting `model.eval()` does not crash the code, but it leads to incorrect predictions during inference because layers like `nn.Dropout` remain active.
\hyperlink{q:6_3}{[Back to Question]}
- \hypertarget{sol:6_4}{**Subchapter 6.4 Solution**}: **Correct Answer: B**
Using threshold 0.5, the predicted labels are: [1, 1, 0, 0, 0]. Comparing with true labels [1, 0, 1, 0, 0]: TP = 1 (sample 0), FP = 1 (sample 1), FN = 1 (sample 2), TN = 2 (samples 3, 4). Precision = $\frac{1}{1+1} = 0.5$. Recall = $\frac{1}{1+1} = 0.5$. F1 = $2 \cdot \frac{0.5 \cdot 0.5}{0.5 + 0.5} = 0.5$. Option A ($0.667$) would require either no false positives or no false negatives. Option C ($0.75$) and D ($1.0$) are unattainable with this confusion matrix.
\hyperlink{q:6_4}{[Back to Question]}