[Week 13] Why Deep Learning Changed the World: High-Dimensional Image Classification ๐ธ
English translation
Welcome to Week 13!
So far, we have worked with data containing relatively few numbers and features,
such as Iris and Wine.
Today, we will experience the power of Deep Learning and why it changed the world.
We will work with High-Dimensional data, specifically image data.
Using the Fashion-MNIST dataset,
compare a simple linear model (Logistic Regression) with a Deep Learning model (MLP).
Today we will find out "Why is Deep Learning powerful?"
๐ผ๏ธ Module 1: Image Data Is Numbers
What Is the Fashion-MNIST Dataset?
- A well-known Machine Learning 'public dataset' containing images of 10 types of fashion items
(T-shirts, trousers, shoes, bags, etc.).\ - A total of 70,000 grayscale images (60,000 for training and 10,000 for testing)
- Each image is 28 pixels wide x 28 pixels high.

How Do We Treat Images as 'Data'?
- Each pixel in a grayscale image has a brightness value from 0 (black) to 255 (white).
- 28 x 28 = 784
- Thus, one image can be represented as a 1-dimensional Vector consisting of 784 numbers!
- Our model must use these 784 Features to classify an image into one of 10 item types.
[Step 1] Prepare and Explore the Data
Besides the load_ function,
scikit-learn can load larger and more varied online datasets using fetch_openml.
# Import required libraries
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_openml # Load an OpenML dataset
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression # Linear model for comparison
from sklearn.neural_network import MLPClassifier # Deep Learning model
from sklearn.preprocessing import StandardScaler # Scaling
from sklearn.metrics import accuracy_score, classification_report
# 1. Load Fashion-MNIST data
# fetch_openml: Load a dataset by name from OpenML.org.
# name='Fashion-MNIST': Dataset name
# version=1: Dataset version
# as_frame=False: Return NumPy arrays rather than a Pandas DataFrame
# parser='auto': (Current scikit-learn recommendation)
fashion_mnist = fetch_openml('Fashion-MNIST', version=1,
as_frame=False, parser='auto')
X, y = fashion_mnist.data, fashion_mnist.target
print("๋ฐ์ดํฐ ๋ก๋ ์๋ฃ!")
# X has shape (70000, 784); y has shape (70000,)
print(f"X (์ด๋ฏธ์ง ๋ฐ์ดํฐ) Shape: {X.shape}")
print(f"y (์ ๋ต ๋ ์ด๋ธ) Shape: {y.shape}")
# Inspect y labels (strings '0' ~ '9')
print(f"\n๋ ์ด๋ธ ์ข
๋ฅ (์๋ณธ): {np.unique(y)}")
# Convert y from strings to integers for model training
y = y.astype(int)
print(f"๋ ์ด๋ธ ์ข
๋ฅ (์ ์ํ): {np.unique(y)}")๋ฐ์ดํฐ ๋ก๋ ์๋ฃ! X (์ด๋ฏธ์ง ๋ฐ์ดํฐ) Shape: (70000, 784) y (์ ๋ต ๋ ์ด๋ธ) Shape: (70000,) ๋ ์ด๋ธ ์ข ๋ฅ (์๋ณธ): ['0' '1' '2' '3' '4' '5' '6' '7' '8' '9'] ๋ ์ด๋ธ ์ข ๋ฅ (์ ์ํ): [0 1 2 3 4 5 6 7 8 9]
Select and visualize one data point
to check whether the 784 numbers in X really form an image.
# Select the first image from X (784 numbers)
first_image_data = X[0]
# Reshape the 1-dimensional vector of 784 elements into a 28x28 2-dimensional matrix
first_image = first_image_data.reshape(28, 28)
# Define the names of the 10 classes
class_names = ["T-shirt/top", "Trouser", "Pullover",
"Dress", "Coat", "Sandal", "Shirt",
"Sneaker", "Bag", "Ankle boot"]
# plt.imshow() : Display a 2D array as an image
plt.figure(figsize=(6, 6))
# cmap='gray': Use the grayscale colormap
plt.imshow(first_image, cmap='gray')
plt.title(f"Label: {y[0]} ({class_names[y[0]]})")
plt.colorbar(label="Pixel Intensity")
plt.show()
print(f"X[0]์ Shape: {first_image_data.shape}")
print(f"first_image์ Shape: {first_image.shape}")<Figure size 600x600 with 2 Axes>
X[0]์ Shape: (784,) first_image์ Shape: (28, 28)
Module 2: Data Preprocessing and a Baseline Model (Logistic Regression)
[Step 2] Data Scaling and Splitting
1. Scaling
- The current pixel values range from 0 to 255.
- Neural Network models such as MLPs are sensitive to input scales.
- Normalizing the 0โ255 range to 0โ1
makes training much faster and more stable. - Method: Divide all pixel values by 255.0.
2. Split Data
- Fashion-MNIST conventionally uses
60,000 samples for training (Train) and 10,000 for testing (Test). train_test_split
# 1. Scaling (0-1 Normalization)
# Divide by the maximum pixel value, 255.0, to obtain the 0~1 range.
X_scaled = X / 255.0
# Inspect scaling results
print(f"์ค์ผ์ผ๋ง ์ (Min: {X.min()}, Max: {X.max()})")
print(f"์ค์ผ์ผ๋ง ํ (Min: {X_scaled.min()}, Max: {X_scaled.max()})")
# 2. Split data
# Set Test size to 10000 and Train size to 60000
X_train, X_test, y_train, y_test = train_test_split(
X_scaled, y, test_size=10000, random_state=42, stratify=y
)
print(f"\nTrain ๋ฐ์ดํฐ Shape: {X_train.shape}")
print(f"Test ๋ฐ์ดํฐ Shape: {X_test.shape}")์ค์ผ์ผ๋ง ์ (Min: 0, Max: 255) ์ค์ผ์ผ๋ง ํ (Min: 0.0, Max: 1.0) Train ๋ฐ์ดํฐ Shape: (60000, 784) Test ๋ฐ์ดํฐ Shape: (10000, 784)
[Step 3] Train the Baseline Model: Logistic Regression
To assess how good a Deep Learning model is,
you need a baseline for comparison.
Here, we will use a baseline that takes 784 pixel values as inputs and classifies them into 10 classes:
a Logistic Regression model.
# 1. Create and train a Logistic Regression model
# solver='saga'
# max_iter=30 : Set the iteration count for this large dataset (30 iterations due to time constraints)
print("๋ก์ง์คํฑ ํ๊ท ๋ชจ๋ธ ํ์ต ์์... (๋ช ๋ถ ์ ๋ ์์๋ ์ ์์ต๋๋ค)")
model_lr = LogisticRegression(
solver='saga',
max_iter=30, # More iterations may be needed in practice
random_state=42
)
model_lr.fit(X_train, y_train)
# 2. Evaluate performance
y_pred_lr = model_lr.predict(X_test)
accuracy_lr = accuracy_score(y_test, y_pred_lr)
print("\n--- ๋ก์ง์คํฑ ํ๊ท ์ฑ๋ฅ ํ๊ฐ (Test Data) ---")
print(f"์ ํ๋: {accuracy_lr:.4f}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred_lr,
target_names=class_names))๋ก์ง์คํฑ ํ๊ท ๋ชจ๋ธ ํ์ต ์์... (๋ช ๋ถ ์ ๋ ์์๋ ์ ์์ต๋๋ค)
--- ๋ก์ง์คํฑ ํ๊ท ์ฑ๋ฅ ํ๊ฐ (Test Data) ---
์ ํ๋: 0.8546
Classification Report:
precision recall f1-score support
T-shirt/top 0.78 0.84 0.81 1000
Trouser 0.98 0.96 0.97 1000
Pullover 0.76 0.74 0.75 1000
Dress 0.84 0.85 0.84 1000
Coat 0.74 0.82 0.78 1000
Sandal 0.95 0.94 0.95 1000
Shirt 0.65 0.56 0.60 1000
Sneaker 0.93 0.93 0.93 1000
Bag 0.96 0.95 0.96 1000
Ankle boot 0.95 0.95 0.95 1000
accuracy 0.85 10000
macro avg 0.85 0.85 0.85 10000
weighted avg 0.85 0.85 0.85 10000
/usr/local/lib/python3.12/dist-packages/sklearn/linear_model/_sag.py:348: ConvergenceWarning: The max_iter was reached which means the coef_ did not converge warnings.warn(
๐ Module 3: Classifying High-Dimensional Images with MLP
[Step 4] Train and Evaluate the Deep Learning Model (MLP)
Now train MLP on the same data.
MLP takes 784 pixels as inputs
and learns their complex combinations (patterns) in hidden layers.
# 1. Create an MLP model
# hidden_layer_sizes=(200, 100, 50, 10): 4 hidden layers with 200, 100, 50, 10 neurons
# This structure learns combinations (patterns) of the 784 pixels.
print("MLP ๋ชจ๋ธ ํ์ต ์์... (์๊ฐ์ด ๋ ์ค๋ ๊ฑธ๋ฆด ์ ์์ต๋๋ค)")
model_mlp = MLPClassifier(
hidden_layer_sizes=(200, 100, 50, 10),
activation='relu',
max_iter=30,
random_state=42,
solver='adam'
)
# 2. Train the model
model_mlp.fit(X_train, y_train)
# 3. Evaluate performance
y_pred_mlp = model_mlp.predict(X_test)
accuracy_mlp = accuracy_score(y_test, y_pred_mlp)
print("\n--- MLPClassifier ์ฑ๋ฅ ํ๊ฐ (Test Data) ---")
print(f"์ ํ๋: {accuracy_mlp:.4f}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred_mlp,
target_names=class_names))MLP ๋ชจ๋ธ ํ์ต ์์... (์๊ฐ์ด ๋ ์ค๋ ๊ฑธ๋ฆด ์ ์์ต๋๋ค)
--- MLPClassifier ์ฑ๋ฅ ํ๊ฐ (Test Data) ---
์ ํ๋: 0.8947
Classification Report:
precision recall f1-score support
T-shirt/top 0.84 0.88 0.86 1000
Trouser 0.99 0.97 0.98 1000
Pullover 0.82 0.81 0.81 1000
Dress 0.91 0.91 0.91 1000
Coat 0.85 0.79 0.82 1000
Sandal 0.97 0.95 0.96 1000
Shirt 0.70 0.75 0.72 1000
Sneaker 0.94 0.95 0.95 1000
Bag 0.96 0.98 0.97 1000
Ankle boot 0.96 0.96 0.96 1000
accuracy 0.89 10000
macro avg 0.90 0.89 0.90 10000
weighted avg 0.90 0.89 0.90 10000
/usr/local/lib/python3.12/dist-packages/sklearn/neural_network/_multilayer_perceptron.py:691: ConvergenceWarning: Stochastic Optimizer: Maximum iterations (30) reached and the optimization hasn't converged yet. warnings.warn(
[Step 5] Compare and Visualize Results
Compare the performance of a baseline model (Logistic Regression) and an MLP,
and examine what the models confuse.
# 1. Compare performance
print("--- ์ต์ข
์ฑ๋ฅ ๋น๊ต ---")
print(f"๋ก์ง์คํฑ ํ๊ท (์ ํ ๋ชจ๋ธ) ์ ํ๋: {accuracy_lr:.4f}")
print(f"MLP (๋ฅ๋ฌ๋ ๋ชจ๋ธ) ์ ํ๋: {accuracy_mlp:.4f}")
print(f"\n์ฑ๋ฅ ํฅ์: {accuracy_mlp - accuracy_lr:.4f}")
print("\n=> MLP๊ฐ ์๋์ธต์ ํตํด '์ค์ค๋ก' ํฝ์
ํจํด์ ํ์ตํ์ฌ")
print(" ๋จ์ ์ ํ ๋ชจ๋ธ๋ณด๋ค ํจ์ฌ ๋์ ์ฑ๋ฅ์ ๋ฌ์ฑํ์ต๋๋ค!")
# 2. Visualize incorrect MLP predictions
# Find indices where y_test and y_pred_mlp differ.
misclassified_indices = np.where(y_test != y_pred_mlp)[0]
# Randomly select 6 incorrect predictions
plt.figure()
for i, idx in enumerate(np.random.choice(misclassified_indices,
6, replace=False)):
plt.subplot(2, 3, i + 1)
# Since X_test is scaled, restore the original pixel values (0~255) for visualization
# by multiplying by 255.
image = (X_test[idx] * 255.0).reshape(28, 28)
plt.imshow(image, cmap='gray')
actual_label = class_names[y_test[idx]]
predicted_label = class_names[y_pred_mlp[idx]]
plt.title(f"Actual: {actual_label}\nPredicted: {predicted_label}",
fontsize=10)
plt.axis('off')
plt.suptitle("Mistakes", fontsize=16)
plt.show()
print("\n=> ์ฌ๋์ด ๋ณด๊ธฐ์๋ ํท๊ฐ๋ฆด ๋งํ ์ด๋ฏธ์ง๋ค(e.g., Pullover/Shirt/Coat)")--- ์ต์ข ์ฑ๋ฅ ๋น๊ต --- ๋ก์ง์คํฑ ํ๊ท (์ ํ ๋ชจ๋ธ) ์ ํ๋: 0.8546 MLP (๋ฅ๋ฌ๋ ๋ชจ๋ธ) ์ ํ๋: 0.8947 ์ฑ๋ฅ ํฅ์: 0.0401 => MLP๊ฐ ์๋์ธต์ ํตํด '์ค์ค๋ก' ํฝ์ ํจํด์ ํ์ตํ์ฌ ๋จ์ ์ ํ ๋ชจ๋ธ๋ณด๋ค ํจ์ฌ ๋์ ์ฑ๋ฅ์ ๋ฌ์ฑํ์ต๋๋ค!
<Figure size 640x480 with 6 Axes>
=> ์ฌ๋์ด ๋ณด๊ธฐ์๋ ํท๊ฐ๋ฆด ๋งํ ์ด๋ฏธ์ง๋ค(e.g., Pullover/Shirt/Coat)
๐ก [Supplement] "If CNN broke the winter, why does MLP work well?"
In the Week 2 history lecture, we mentioned the 'AI winter'.
(More precisely, the 'second AI winter' of the 1990s.)
We learned that 'AlexNet' (a type of CNN) ended this winter in 2012.
Why, then, did using an MLP rather than a CNN today
yield strong performance exceeding 88% (far better than the linear model)
on complex, 784-dimensional image data?
This question gets to the heart of Deep Learning.
The main technical reason MLPs failed in the 90s
was the 'Vanishing Gradient' problem.
As models became deeper,
the learning signal (Gradient) vanished toward 0 during Backpropagation,
leaving neurons in earlier layers unable to learn.
This was a mathematical limitation of the Sigmoid activation function used at the time.
AlexNet (CNN) broke this winter in 2012
not only through CNN's strong architecture (learning spatial information),
but also by using key technologies that addressed the underlying causes of the AI winter:
- ReLU Activation Function: Use ReLU instead of Sigmoid to address vanishing gradients.
- GPU parallel processing: Fast large-scale computation.
- Large datasets: Sufficient training data such as 'ImageNet'.
The MLP we used today is not the MLP of the '90s.
Our MLPClassifier used the same key technologies that broke the AI winter.
activation='relu': We used ReLU, which resolved vanishing gradients.solver='adam': We used a modern optimization algorithm that manages gradients efficiently.- We used large-scale data called Fashion-MNIST.
In other words, technical advances made it possible to train MLPs even when stacked deeply,
allowing hidden layers to learn complex patterns from 784 pixel inputs on their own.
๐ง Module 4: A Taste of CNNs, Image Specialists (PyTorch)
CNN (Convolutional Neural Network)
Although MLP performed much better than the linear model,
it is still not optimized for image classification.
Fundamental Limitations of MLPs:
- Loss of Spatial Information:
- MLP takes images as 1-dimensional vectors in the form
(784,). - This removes the pixels' 2-dimensional spatial information (above/below/left/right relationships).
- The model does not know that pixels (0,0) and (0,1) are neighbors.
- MLP takes images as 1-dimensional vectors in the form
- Parameter Inefficiency:
- It must learn the pattern of an ankle boot's heel at the bottom left
and the pattern at the bottom right separately. - Relearning the same 'heel' feature at each position is very inefficient.
- It must learn the pattern of an ankle boot's heel at the bottom left
The Idea behind CNNs (Convolutional Neural Networks):
- A CNN takes an image in its 2-dimensional (28x28) form rather than flattening it into 1 dimension.
- Key idea: Small 'Filters' (e.g., 3x3) detect features (e.g., lines, corners, curves)
like a magnifying glass or scanner, Sliding across the entire image. - Convolution: The filter overlaps a particular image region
and calculates how closely that region matches the filter's pattern.
Why CNNs are powerful:
- Preserve Spatial Information: Using the 2D structure directly lets it learn neighboring relationships between pixels.
- Parameter Sharing: A 'single' filter scans the 'entire' image.
- Learn a filter for the 'heel' feature just once,
and the same filter can detect it on either side of the image. - This dramatically reduces the number of parameters to learn compared with an MLP,
enabling much more efficient and powerful learning.
- Learn a filter for the 'heel' feature just once,
A CNN stacks multiple (Convolution + pooling) layers,
learning simple lines/shapes first and then combining them into
more complex, abstract features (e.g., eyes, noses, wheels).
This is how AlexNet ended the AI winter in 2012.
[Reference] CNN Implementation Example Using PyTorch
scikit-learn does not support CNNs.
A dedicated Deep Learning library such as PyTorch or TensorFlow is required.
Below is an example implementation of a simple CNN using PyTorch.
(It may take several minutes or longer to run.)
You do not need to fully understand this code yet.
The goal is to understand how much its results (accuracy) differ from MLP's.
Pay particular attention to the structure of class SimpleCNN(nn.Module):.
# Import PyTorch
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
# 1. Reshape data for PyTorch Tensors
# Convert NumPy arrays to PyTorch tensors
X_train_tensor = torch.Tensor(X_train)
y_train_tensor = torch.LongTensor(y_train) # Classification targets use LongTensor
X_test_tensor = torch.Tensor(X_test)
y_test_tensor = torch.LongTensor(y_test)
# CNN requires 4D inputs: (sample count, channel count, height, width).
# (60000, 784) -> (60000, 1, 28, 28) (1 is the grayscale channel)
X_train_cnn = X_train_tensor.reshape(-1, 1, 28, 28)
X_test_cnn = X_test_tensor.reshape(-1, 1, 28, 28)
# DataLoader: Utility for grouping data into minibatches
train_dataset = TensorDataset(X_train_cnn, y_train_tensor)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_dataset = TensorDataset(X_test_cnn, y_test_tensor)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
# 2. Define the CNN model (PyTorch nn.Module)
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
# 1. Convolution Layer: Takes 1 grayscale channel as input,
# and scans the 28x28 image with 16 'scanners'
self.conv1 = nn.Conv2d(in_channels=1, out_channels=16,
kernel_size=3, stride=1, padding=1)
self.relu = nn.ReLU()
# 2. MaxPooling Layer: Extracts the strongest feature in each 2x2 region,
# reducing image size (28x28 -> 14x14)
self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
# 3. Flatten Layer: Convert 2D feature maps -> 1D vector (for MLP input)
self.flatten = nn.Flatten()
# 4. Dense Layer (MLP): Compress a vector of size 16*14*14 into 128 neurons
self.fc1 = nn.Linear(16 * 14 * 14, 128)
# 5. Output Layer (MLP): Classify 128 neurons into 10 classes (0~9)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
# Define the data flow
x = self.pool(self.relu(self.conv1(x))) # Conv -> ReLU -> Pool
x = self.flatten(x) # Flatten
x = self.relu(self.fc1(x)) # Dense -> ReLU
x = self.fc2(x) # Output
return x
model_cnn = SimpleCNN()
criterion = nn.CrossEntropyLoss() # Cost function for multiclass classification
optimizer = optim.Adam(model_cnn.parameters(), lr=0.001) # Optimization algorithm
# 3. Train the model (only 10 Epochs due to time constraints)
print("\nPyTorch CNN ๋ชจ๋ธ ํ์ต ์์...")
model_cnn.train() # Set the model to training mode
for epoch in range(10): # Train over the entire dataset 10 times
for inputs, labels in train_loader:
optimizer.zero_grad() # Reset gradients
outputs = model_cnn(inputs) # Forward pass
loss = criterion(outputs, labels) # Calculate error
loss.backward() # Backpropagation
optimizer.step() # Update weights
print(f"Epoch {epoch+1}/10, Loss: {loss.item():.4f}")
# 4. Evaluate the model
model_cnn.eval() # Set the model to evaluation mode
correct = 0
total = 0
with torch.no_grad(): # Disable gradient computation
for inputs, labels in test_loader:
outputs = model_cnn(inputs)
_, predicted = torch.max(outputs.data, 1) # Select the class with the highest probability
total += labels.size(0)
correct += (predicted == labels).sum().item()
accuracy_cnn = correct / total
print(f"\n--- CNN ์ฑ๋ฅ ํ๊ฐ (Test Data) ---")
print(f"์ ํ๋: {accuracy_cnn:.4f}")
# 5. Compare final performance
print("\n--- ์ต์ข
์ฑ๋ฅ ๋น๊ต ---")
print(f"๋ก์ง์คํฑ ํ๊ท (์ ํ ๋ชจ๋ธ) ์ ํ๋: {accuracy_lr:.4f}")
print(f"MLP (๋ฅ๋ฌ๋ ๋ชจ๋ธ) ์ ํ๋: {accuracy_mlp:.4f}")
print(f"CNN (๋ฅ๋ฌ๋ ์ ๋ฌธ๊ฐ) ์ ํ๋: {accuracy_cnn:.4f}")
print("\n=> CNN์ด ์ด๋ฏธ์ง์ '๊ณต๊ฐ ์ ๋ณด'์ 'ํจํด'์ ํจ์ฌ ํจ์จ์ ์ผ๋ก ํ์ตํ์ฌ")
print(" MLP๋ณด๋ค ๋ ๋์ ์ฑ๋ฅ์ ๋ฌ์ฑํ์ต๋๋ค!")PyTorch CNN ๋ชจ๋ธ ํ์ต ์์... Epoch 1/10, Loss: 0.3730 Epoch 2/10, Loss: 0.2957 Epoch 3/10, Loss: 0.3789 Epoch 4/10, Loss: 0.3020 Epoch 5/10, Loss: 0.2208 Epoch 6/10, Loss: 0.0739 Epoch 7/10, Loss: 0.0374 Epoch 8/10, Loss: 0.0779 Epoch 9/10, Loss: 0.1845 Epoch 10/10, Loss: 0.0245 --- CNN ์ฑ๋ฅ ํ๊ฐ (Test Data) --- ์ ํ๋: 0.9189 --- ์ต์ข ์ฑ๋ฅ ๋น๊ต --- ๋ก์ง์คํฑ ํ๊ท (์ ํ ๋ชจ๋ธ) ์ ํ๋: 0.8546 MLP (๋ฅ๋ฌ๋ ๋ชจ๋ธ) ์ ํ๋: 0.8947 CNN (๋ฅ๋ฌ๋ ์ ๋ฌธ๊ฐ) ์ ํ๋: 0.9189 => CNN์ด ์ด๋ฏธ์ง์ '๊ณต๊ฐ ์ ๋ณด'์ 'ํจํด'์ ํจ์ฌ ํจ์จ์ ์ผ๋ก ํ์ตํ์ฌ MLP๋ณด๋ค ๋ ๋์ ์ฑ๋ฅ์ ๋ฌ์ฑํ์ต๋๋ค!
โจ Wrap-up
Today, we worked with high-dimensional, 784-dimensional image data.
- Limitations of linear models: Logistic Regression could not learn complex pixel combinations and showed lower performance (approximately 85%).
- The Power of MLPs: Through 'hidden layers' and 'nonlinear activation (ReLU)',
an MLP learns features (patterns) 'on its own' from data and achieves higher performance (about 89%). - CNN Specialization: An MLP loses 'spatial information' between pixels,
but a CNN uses this information through 'Convolution'
and achieves much stronger image recognition performance (about 92%).
This is why Deep Learning, especially CNNs, ended the AI winter and transformed image and speech recognition.
Next time, we will undertake a final project that brings this entire process together.