Ở Bài 3, chúng ta đã nắm vững nguyên lý vector hóa và cơ chế phát thanh chiều (Broadcasting) bằng thư viện NumPy. Tuy nhiên, NumPy chỉ hoạt động trên CPU và không hỗ trợ tính toán đạo hàm tự động. Để huấn luyện những kiến trúc học sâu khổng lồ như Transformer hay mạng CNN phức tạp, chúng ta cần một công cụ mạnh mẽ hơn: PyTorch.

Bài học này sẽ hướng dẫn bạn tiếp cận thư viện học sâu PyTorch thông qua cấu trúc dữ liệu cốt lõi Tensor, cách thức kiểm soát chiều không gian dữ liệu bằng cơ chế Reshaping và tìm hiểu chi tiết động cơ tính đạo hàm tự động Autograd - xương sống của quá trình lan truyền ngược (Backpropagation).

4.1 Tensor là gì? Khác gì mảng NumPy?

Về mặt toán học và cấu trúc lưu trữ bộ nhớ, một Tensor của PyTorch tương đồng đến 99% với mảng ndarray của NumPy. Nó cũng là một mảng đa chiều chứa các phần tử có cùng kiểu dữ liệu được sắp xếp liên tục trong RAM. Tuy nhiên, Tensor của PyTorch được trang bị hai vũ khí siêu việt chuyên biệt cho học sâu:

  1. Tăng tốc phần cứng bằng GPU: PyTorch Tensor có thể dễ dàng chuyển đổi tính toán từ CPU sang các dòng chip đồ họa GPU hiệu năng cao như NVIDIA (thông qua CUDA) hoặc chip Apple Silicon (thông qua MPS) để song song hóa hàng triệu phép toán ma trận cùng lúc.
  2. Đạo hàm tự động (Autograd): Mỗi Tensor có thể tự theo dõi lịch sử tính toán của chính nó và tự động tính đạo hàm (Gradient) tại bất kỳ mắt xích nào trong đồ thị tính toán.
🧠 CPU vs GPU — Bản chất song song hóa phần cứng
Tại sao các mô hình học sâu bắt buộc phải huấn luyện trên GPU?

Bộ vi xử lý CPU được thiết kế để xử lý tuần tự các tác vụ phức tạp với số lượng nhân (Core) rất ít (thường từ 4 đến 64 nhân) nhưng tốc độ mỗi nhân cực kỳ cao và có bộ đệm cache lớn. Trái lại, card đồ họa GPU được thiết kế cho xử lý song song khối lượng cực lớn dữ liệu hình học đơn giản. Nó chứa hàng ngàn nhân tính toán nhỏ (ALU - Arithmetic Logic Unit).

Phép toán cốt lõi của mạng nơ-ron là nhân ma trận lớn, vốn được phân rã thành hàng triệu phép nhân và cộng số học độc lập. GPU có thể phân chia hàng triệu phép toán này cho hàng ngàn nhân ALU chạy đồng thời trong một chu kỳ nhịp, tăng tốc độ huấn luyện mô hình lên gấp hàng chục, hàng trăm lần so với CPU.

Trước khi tính toán, ta cần nói cho PyTorch biết Tensor này nằm ở đâu: trong RAM thường (CPU) hay trong bộ nhớ của card đồ họa. Đoạn code dưới đây làm hai việc — tự dò xem máy bạn có phần cứng tăng tốc nào, rồi chuyển Tensor sang đó bằng thuộc tính device:

tensor_device.py
import torch

# A tensor starts life on the CPU unless you say otherwise.
x = torch.tensor([[1.0, 2.0], [3.0, 4.0]])

# Pick the best hardware available, in order of preference.
device = (
    "cuda" if torch.cuda.is_available()
    else "mps" if torch.backends.mps.is_available()
    else "cpu"
)
print(f"Using device: {device}")

# .to() returns a COPY on the target device. It does not move x in place.
x_gpu = x.to(device)
print(x_gpu)

Chuỗi if...else nhiều tầng đó là cách viết Python tương đương toán tử ba ngôi lồng nhau trong JavaScript, và thứ tự ưu tiên có ý nghĩa: cuda là card NVIDIA rời (nhanh nhất), mps là chip Apple Silicon trên máy Mac, còn cpu là phương án cuối cùng luôn chạy được. Trên MacBook M-series bạn sẽ thấy in ra Using device: mps; trên máy không có GPU thì là cpu — và bài học vẫn chạy bình thường, chỉ chậm hơn.

Một chi tiết dễ hiểu nhầm nằm ở dòng cuối: x.to(device) không di chuyển x, nó trả về một bản sao mới nằm trên thiết bị đích. Biến x gốc vẫn ở nguyên CPU. Quên gán lại kết quả — viết x.to(device) rồi tiếp tục dùng x — là cách nhanh nhất để rơi vào đúng cạm bẫy ngay bên dưới.

⚠️ Cạm bẫy: Tính toán chéo thiết bị (Device Mismatch)
PyTorch nghiêm cấm thực hiện các phép toán giữa các Tensor nằm ở các thiết bị phần cứng khác nhau. Nếu bạn thực hiện cộng một Tensor trên CPU với một Tensor trên GPU, chương trình sẽ crash ngay lập tức với lỗi: RuntimeError: Expected all tensors to be on the same device.... Hãy luôn nhớ gọi .to(device) đồng bộ cho mọi biến đầu vào và bộ trọng số trước khi tính toán.

Vì cả hai đều lưu dữ liệu liên tục trong RAM theo cùng cách, PyTorch cho phép chuyển đổi qua lại giữa Tensor (trên CPU) và mảng NumPy gần như miễn phí bằng .numpy()torch.from_numpy() — nhưng "gần như miễn phí" ở đây ẩn chứa một cạm bẫy quan trọng khác:

⚠️ Cạm bẫy: Tensor CPU và mảng NumPy CHIA SẺ CHUNG một vùng nhớ
tensor.numpy()torch.from_numpy(array) KHÔNG sao chép dữ liệu — chúng chỉ tạo ra một "cửa sổ nhìn" khác trên CÙNG một vùng RAM (chỉ áp dụng cho Tensor đang ở CPU). Nếu bạn sửa đổi mảng NumPy, Tensor gốc cũng bị thay đổi theo NGAY LẬP TỨC mà không có bất kỳ cảnh báo nào — đây là nguồn gốc của rất nhiều bug "dữ liệu tự nhiên bị sai" cực kỳ khó truy trong pipeline tiền xử lý dữ liệu.
tensor_numpy_shared_memory.py
import torch
import numpy as np

t = torch.ones(3)
n = t.numpy()  # No copy — n and t point at the same block of RAM.

n[0] = 99.0  # Change the NumPy array...
print(t)     # ...and the tensor changed too: tensor([99., 1., 1.])

# Want them independent? Copy on purpose.
n_independent = t.numpy().copy()
n_independent[0] = -1.0
print(t)  # Unchanged this time: tensor([99., 1., 1.])

Nếu bạn quen với JavaScript, hành vi này giống hệt việc gán một object: const b = a không tạo ra object mới, nên sửa b.x thì a.x cũng đổi. Điểm khác biệt nguy hiểm là ở đây hai biến thuộc hai thư viện khác nhau và trông không có vẻ gì liên quan, nên khi dữ liệu đột nhiên sai ở cuối pipeline, gần như không ai nghĩ tới nguyên nhân này.

4.2 Biến đổi hình dạng (Tensor Reshaping & View)

Ta vừa xem Tensor chứa gì và nằm ở đâu. Phần này nói về việc đổi hình dạng của nó — thao tác bạn sẽ gặp ở gần như mọi tầng mạng nơ-ron từ Bài 5 trở đi.

"Hình dạng" (shape) là bộ kích thước từng chiều của Tensor: một ảnh xám 28×28 có shape (28, 28), còn một lô 64 ảnh như vậy có shape (64, 28, 28). Mỗi tầng mạng nơ-ron lại đòi hỏi đầu vào ở một shape khác nhau, nên việc "gấp lại" dữ liệu cho vừa khuôn xảy ra liên tục. PyTorch có hai phương thức làm việc đó là .view().reshape() — trông như nhau nhưng khác nhau ở một điểm khiến người mới hay gặp lỗi runtime.

🧠 Khái niệm Memory Contiguity (Tính liên tục của bộ nhớ)
Một Tensor được gọi là contiguous (liên tục) nếu các phần tử của nó được xếp đúng theo thứ tự dòng-cột liên tiếp nhau trong RAM vật lý.

Khi chúng ta gọi phép toán chuyển vị transpose() hoặc t() hoặc permute(), PyTorch không hề sắp xếp lại dữ liệu trong RAM (vì sẽ làm chậm hệ thống). Nó chỉ thay đổi cách tính toán chỉ số tọa độ (metadata). Điều này làm Tensor bị mất tính liên tục (non-contiguous).
  • .view(): Chỉ tạo ra một "góc nhìn" mới trên cùng vùng RAM gốc cũ. Do không sao chép dữ liệu, view() bắt buộc Tensor gốc phải contiguous. Nếu không, nó sẽ báo lỗi RuntimeError: view size is not compatible with input tensor's size and stride.
  • .reshape(): Hoạt động an toàn hơn. Nếu Tensor gốc contiguous, nó trả về một view (không sao chép). Nếu Tensor gốc non-contiguous, nó tự động gọi ngầm hàm sao chép .clone().contiguous() để tạo ra một vùng nhớ liên tục mới rồi mới thay đổi chiều.

Lý thuyết trên chỉ thực sự rõ khi thấy nó crash. Đoạn code sau làm đúng một việc: đổi cùng một Tensor sang shape (6, 1) hai lần — một lần khi nó còn liên tục, một lần sau khi chuyển vị làm nó mất tính liên tục:

tensor_reshape.py
import torch

a = torch.tensor([[1, 2, 3], [4, 5, 6]])  # shape (2, 3), contiguous
print(a.is_contiguous())  # True

b = a.view(6, 1)  # Fine: no copy, just a different way of indexing the same RAM.

a_t = a.t()  # Transpose -> shape (3, 2), but the RAM layout did NOT change.
print(a_t.is_contiguous())  # False

try:
    a_t.view(6, 1)
except RuntimeError as e:
    print("Error:", e)
    # Error: view size is not compatible with input tensor's size and stride...

c = a_t.reshape(6, 1)               # Option 1: reshape copies when it has to.
d = a_t.contiguous().view(6, 1)     # Option 2: make it contiguous yourself first.

Hai dòng print đầu là cả bài học: cùng một dữ liệu, nhưng a.is_contiguous() trả về True còn a_t.is_contiguous() trả về False. Phép chuyển vị không hề chạm vào RAM — nó chỉ đổi cách PyTorch diễn giải vùng RAM đó — và vì .view() yêu cầu dữ liệu nằm liên tục, nó từ chối làm việc.

💡 Vậy nên dùng cái nào?
Trong code hằng ngày, hãy mặc định dùng .reshape() — nó luôn chạy, và khi Tensor đã liên tục thì nó cũng không sao chép gì cả, tức là không chậm hơn .view().

Dùng .view() khi bạn muốn nó báo lỗi: nếu code của bạn phụ thuộc vào việc không có bản sao nào được tạo ra (ví dụ đang tối ưu bộ nhớ trên GPU), thì một lỗi runtime ồn ào tốt hơn nhiều so với một bản sao âm thầm ngốn thêm RAM.

Bên cạnh đó, ta có hai hàm hỗ trợ thêm/bớt chiều ảo vô cùng tiện lợi:

  • .squeeze(): Loại bỏ mọi chiều có kích thước bằng 1. Ví dụ mảng shape (1, 5, 1) sau khi squeeze sẽ thành (5,).
  • .unsqueeze(dim): Thêm một chiều ảo kích thước bằng 1 tại vị trí dim chỉ định. Ví dụ mảng shape (5,) gọi .unsqueeze(1) sẽ thành (5, 1).

Hai hàm này nghe có vẻ vụn vặt nhưng bạn sẽ dùng chúng liên tục, vì gần như mọi tầng trong PyTorch đều được viết để xử lý một lô (batch) nhiều mẫu cùng lúc chứ không phải một mẫu đơn lẻ. Khi bạn muốn dự đoán cho đúng một ảnh shape (28, 28), mô hình sẽ từ chối vì nó chờ đợi shape (batch, 28, 28) — và .unsqueeze(0) chính là cách thêm cái chiều "lô gồm 1 mẫu" đó vào. Chiều ngược lại, .squeeze(), dùng để bóc lớp vỏ thừa ra khỏi kết quả trước khi in cho người dùng xem.

4.3 Cơ chế tự động tính đạo hàm (Autograd & Đồ thị tính toán)

Tới đây, mọi thứ ta làm với Tensor đều có thể làm bằng NumPy. Phần này mới là thứ khiến PyTorch tồn tại — và cũng là lý do bạn không phải tự tay tính đạo hàm cho mạng nơ-ron của mình.

Nhắc lại từ Bài 2: để huấn luyện một mô hình, ta cần biết chỉnh mỗi trọng số theo hướng nào thì sai số giảm, và câu trả lời đó chính là đạo hàm riêng (partial derivative) của hàm sai số theo từng trọng số. Ở Bài 2 ta tính đạo hàm bằng tay cho một hàm đúng một biến. Một mạng nơ-ron thật có hàng triệu trọng số và hàng trăm phép toán lồng nhau — tính tay là bất khả thi. PyTorch giải quyết chuyện này bằng động cơ Autograd.

Khi bạn khởi tạo một Tensor và đặt thuộc tính requires_grad=True, PyTorch sẽ bắt đầu theo dõi mọi phép toán tác động lên Tensor đó. Trong quá trình chạy xuôi (Forward pass), PyTorch sẽ tự động dựng lên một Đồ thị tính toán động (Dynamic Computation Graph) dưới dạng đồ thị có hướng không chu trình (DAG).

Mỗi biến được sinh ra từ các phép toán sẽ lưu giữ thuộc tính grad_fn (trỏ tới toán tử đã sinh ra nó, ví dụ <PowBackward0> hay <AddBackward0>). Các nút lá (Leaf nodes) chính là các Tensor đầu vào tự định nghĩa không do phép toán nào tạo ra sẽ có grad_fn = None.

Hãy xem đồ thị tính toán đơn giản sau:

$$y = x^2 \quad \implies \quad z = 2y + 3$$

Hình dưới đây là chính đồ thị đó, vẽ theo đúng cách PyTorch dựng nó trong bộ nhớ. Hàng trên là lượt đi xuôi (forward) khi bạn viết hai dòng code; hàng dưới là lượt đi ngược (backward) mà z.backward() tự chạy:

Lượt đi XUÔI — PyTorch vừa tính vừa ghi lại đường đi x nút lá · =3.0 grad_fn = None bình phương y =9.0 grad_fn = PowBackward0 ×2, +3 z =21.0 grad_fn = AddBackward0 ← gọi .backward() ở đây Lượt đi NGƯỢC — đi lại đúng đường đó, nhân đạo hàm dọc đường dz/dy = 2 dy/dx = 2x z y x Nhân hai đạo hàm dọc đường: dz/dx = 2 × 2x = 4x. Tại x = 3 ta được 12 — chính là số PyTorch ghi vào x.grad. Đó là toàn bộ quy tắc chuỗi: đi ngược một đường, nhân dồn các đạo hàm gặp trên đường đó.

Khi ta gọi phương thức z.backward(), PyTorch sẽ tự động duyệt ngược đồ thị từ kết quả $z$ quay trở lại $x$ để tính đạo hàm theo quy tắc chuỗi (Chain Rule) trong giải tích:

$$\frac{dz}{dx} = \frac{dz}{dy} \cdot \frac{dy}{dx} = 2 \cdot 2x = 4x$$

Điểm đáng chú ý nhất trong hình là các nhãn grad_fn ở hàng trên. Bạn không viết dòng nào để tạo ra chúng — PyTorch tự gắn vào mỗi kết quả trung gian một con trỏ ngược về phép toán đã sinh ra nó. Chính chuỗi con trỏ đó đồ thị, và cũng chính nó cho phép lượt đi ngược biết phải đi đường nào. Riêng xgrad_fn = None vì nó do bạn tự tạo chứ không phải kết quả của phép toán nào — đó là định nghĩa của một nút lá.

autograd_demo.py
import torch

# requires_grad=True turns on the recording. Without it, x.grad stays None.
x = torch.tensor(3.0, requires_grad=True)

# Forward pass: computes the values AND builds the graph at the same time.
y = x ** 2
z = 2 * y + 3
print(y.grad_fn)  # <PowBackward0 object at 0x...>
print(x.grad_fn)  # None — x is a leaf, nothing produced it

# Backward pass: walk the graph in reverse, applying the chain rule.
z.backward()

# Expected: dz/dx = 4x = 4 * 3 = 12
print(f"dz/dx at x=3 is: {x.grad.item():.1f}")  # dz/dx at x=3 is: 12.0

Ba dòng print xác nhận đúng những gì hình vẽ mô tả: y mang theo con trỏ PowBackward0 trỏ về phép bình phương đã sinh ra nó, x không mang gì cả vì nó là nút lá, và sau khi gọi .backward() thì x.grad chứa đúng con số 12. Bạn không hề viết công thức đạo hàm nào — chỉ viết phép tính xuôi, và PyTorch suy ra phần còn lại.

🧠 Vô hiệu hóa đồ thị tính toán với torch.no_grad()
Trong quá trình chạy suy luận (Inference - dự đoán) hoặc cập nhật trọng số thủ công, chúng ta không cần tính đạo hàm. Việc PyTorch liên tục xây dựng đồ thị tính toán động sẽ gây lãng phí bộ nhớ RAM/VRAM cực lớn.

Để vô hiệu hóa cơ chế này, chúng ta sử dụng khối lệnh with torch.no_grad(): hoặc gọi phương thức tensor.detach() để cắt đứt liên kết của Tensor khỏi đồ thị tính toán hiện tại. Điều này giải phóng bộ nhớ và tăng tốc độ xử lý lên tối đa.
⚠️ Cạm bẫy: Đạo hàm tự động cộng dồn (Accumulating Gradients)
Mặc định, PyTorch sẽ cộng dồn (accumulate) giá trị đạo hàm mới vào thuộc tính .grad sau mỗi lần gọi .backward(). Điều này hữu ích trong huấn luyện mạng quy mô lớn, nhưng đối với các vòng lặp tối ưu hóa thông thường, nó sẽ làm sai lệch hoàn toàn hướng đi của Gradient Descent. Bạn bắt buộc phải gọi .grad.zero_() hoặc optimizer.zero_grad() để reset đạo hàm về 0 sau mỗi vòng cập nhật.

Một lỗi runtime khác cũng cực kỳ phổ biến với người mới: gọi .backward() trên một Tensor KHÔNG PHẢI số vô hướng (scalar):

backward_scalar_only.py
import torch

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x * 2  # y holds 3 values, not a single number

try:
    y.backward()  # Fails
except RuntimeError as e:
    print("Error:", e)
    # Error: grad can be implicitly created only for scalar outputs

# Fix 1: reduce to a scalar first. This is what every real loss function does,
# via .sum() or .mean().
y.sum().backward()
print(x.grad)  # tensor([2., 2., 2.])

# Fix 2: if you genuinely need per-element gradients, pass a weight vector with
# the same shape as y.
x.grad.zero_()
y2 = x * 2
y2.backward(torch.tensor([1.0, 1.0, 1.0]))
print(x.grad)  # tensor([2., 2., 2.]) — same as .sum().backward() here

Đây là lý do vì sao mọi hàm Loss trong huấn luyện thật (MSE, Cross-Entropy...) đều trả về đúng 1 con số vô hướng đại diện cho toàn bộ batch — không phải ngẫu nhiên mà PyTorch yêu cầu vậy, mà vì .backward() mặc định chỉ biết cách lan truyền ngược từ 1 điểm gốc duy nhất.

4.4 Dự án thực hành bài 4: Xấp xỉ hàm số đa thức bằng Autograd

Để kiểm nghiệm sức mạnh của Autograd, chúng ta sẽ tự xây dựng một ứng dụng tối ưu hóa: Sử dụng đa thức bậc 3 để xấp xỉ (fitting) đường hình Sin.

Công thức đa thức bậc 3 cần học của chúng ta là:

$$\hat{y} = w_1 \cdot x + w_2 \cdot x^2 + w_3 \cdot x^3 + b$$

Trong đó $w_1, w_2, w_3, b$ là các tham số sẽ được cập nhật liên tục ngược chiều Gradient để giảm thiểu sai số.

Vì sao lại là bài toán này? Vì nó nhỏ đến mức bạn kiểm tra được kết quả bằng mắt, nhưng có đủ mọi thành phần của một vòng huấn luyện thật: dữ liệu có nhiễu, tham số cần học, hàm sai số, đạo hàm, và bước cập nhật. Ở Bài 5 bạn sẽ thay bốn con số $w_1, w_2, w_3, b$ bằng hàng nghìn trọng số của một mạng nơ-ron — nhưng vòng lặp thì vẫn đúng năm bước như dưới đây.

Trước khi đọc code, có ba thuật ngữ trong đó cần thống nhất:

  • Epoch — một lần duyệt qua toàn bộ dữ liệu huấn luyện. Ta chạy 2.000 epoch, tức là bộ dữ liệu được đưa qua mô hình 2.000 lần, mỗi lần chỉnh trọng số một chút.
  • Learning rate (tốc độ học) — mỗi bước chỉnh trọng số đi xa bao nhiêu theo hướng gradient chỉ ra. Bạn đã gặp nó ở Bài 2: quá lớn thì nhảy vọt qua đáy và sai số phân kỳ ra vô cực; quá nhỏ thì đi đúng hướng nhưng chậm tới mức không kịp hội tụ.
  • Batch (lô) — số mẫu được đưa qua mô hình cùng một lúc. Ở đây ta dùng cả 2.000 mẫu trong một lần, tức batch bằng toàn bộ dữ liệu; các bài sau sẽ chia nhỏ ra khi dữ liệu không còn vừa bộ nhớ.
💡 Vì sao learning rate lại nhỏ tới 0,000001?
Con số 1e-6 trông vô lý cho tới khi bạn nhìn dòng tính sai số: (y_pred - y).pow(2).sum()tổng chứ không phải trung bình. Cộng sai số của 2.000 mẫu lại làm giá trị gradient lớn hơn khoảng 2.000 lần so với khi lấy trung bình, nên tốc độ học phải nhỏ tương ứng để bù lại.

Thử nghiệm thực tế với đúng đoạn code này, giữ nguyên 2.000 epoch và chỉ đổi hai tham số đó:

sum + 1e-6 → sai số cuối 0,0153 (đúng như bài học).
mean + 1e-6 → sai số cuối 103,26 — bước đi nhỏ tới mức sau 2.000 vòng mô hình gần như chưa học được gì.
mean + 1e-3 → sai số cuối 0,0171, tương đương bản gốc.
mean + 1e-2 hoặc sum + 1e-3 → sai số thành nan: bước quá dài, trọng số bị hất văng ra vô cực rồi tràn số.

Hai tham số này phải chỉnh cùng nhau, và đây là một trong những lý do phổ biến nhất khiến code huấn luyện chép từ nơi khác về "không chạy" — chỉ cần đổi cách tính sai số mà quên đổi tốc độ học.

Bạn có thể tải file Python hoàn chỉnh chứa mã nguồn thực hành chi tiết ở khung bên dưới:

autograd_estimation.py
import torch
import math

# Fit a cubic polynomial to sin(x) using nothing but Autograd.
#
#   y_pred = w1*x + w2*x^2 + w3*x^3 + b
#
# There is no nn.Module and no optimizer here on purpose: every weight update is
# written by hand, so you can see exactly what PyTorch does for you later on.


def generate_data(num_samples=2000):
    # x spread evenly across [-pi, pi]
    x = torch.linspace(-math.pi, math.pi, num_samples, dtype=torch.float32)
    # The 0.1 * randn term is Gaussian noise. Real measurements are never clean,
    # and fitting perfectly clean data teaches the wrong lesson about overfitting.
    y = torch.sin(x) + 0.1 * torch.randn(num_samples)
    return x, y


def train_autograd():
    x, y = generate_data()

    # requires_grad=True is the whole trick: from now on PyTorch records every
    # operation these four tensors take part in, so it can differentiate later.
    w1 = torch.randn((), dtype=torch.float32, requires_grad=True)
    w2 = torch.randn((), dtype=torch.float32, requires_grad=True)
    w3 = torch.randn((), dtype=torch.float32, requires_grad=True)
    b = torch.randn((), dtype=torch.float32, requires_grad=True)

    # Hyperparameters. 1e-6 looks tiny, but the loss below is a SUM over 2000
    # samples, so each gradient is roughly 2000x larger than a per-sample one.
    learning_rate = 1e-6
    epochs = 2000

    print("=== Fitting a cubic to sin(x) with PyTorch Autograd ===")
    print(
        f"Initial weights: w1={w1.item():.4f}, w2={w2.item():.4f}, "
        f"w3={w3.item():.4f}, b={b.item():.4f}\n"
    )

    for epoch in range(1, epochs + 1):
        # Forward pass — this line also builds the computation graph.
        y_pred = w1 * x + w2 * (x**2) + w3 * (x**3) + b

        # Squared error, summed. backward() needs a single number to start from.
        loss = (y_pred - y).pow(2).sum()

        # Backward pass: walk the graph in reverse and fill in every .grad.
        loss.backward()

        # The update itself is plain arithmetic, not part of the model, so keep
        # it out of the graph.
        with torch.no_grad():
            w1 -= learning_rate * w1.grad
            w2 -= learning_rate * w2.grad
            w3 -= learning_rate * w3.grad
            b -= learning_rate * b.grad

            # Gradients ACCUMULATE by default. Skip this and epoch 2 optimises
            # using epoch 1 + epoch 2 added together, which points nowhere useful.
            w1.grad.zero_()
            w2.grad.zero_()
            w3.grad.zero_()
            b.grad.zero_()

        if epoch % 200 == 0:
            print(f"Epoch {epoch:4d} | Loss: {loss.item():.4f}")

    print("\n=== Result ===")
    print(
        f"Learned polynomial: y_pred = {w1.item():.4f}*x + {w2.item():.4f}*x^2 "
        f"+ {w3.item():.4f}*x^3 + {b.item():.4f}"
    )
    print("Target function:    y = sin(x)")

    # Report the MEAN squared error, not the sum, so the number is comparable
    # across different dataset sizes.
    with torch.no_grad():
        final_y_pred = w1 * x + w2 * (x**2) + w3 * (x**3) + b
        final_loss = (final_y_pred - y).pow(2).mean()
        print(f"Final mean squared error: {final_loss.item():.6f}")


if __name__ == "__main__":
    train_autograd()
💡 Khối with torch.no_grad() dùng để làm gì?
Khi thực hiện cập nhật trọng số ở đoạn w1 -= learning_rate * w1.grad phía trên, ta đang thực hiện các phép toán số học thông thường chứ không phải các lớp tính toán của mạng. Nếu không bọc trong with torch.no_grad():, PyTorch sẽ tiếp tục theo dõi các phép toán này và tự vẽ thêm các nhánh vô nghĩa vào Đồ thị tính toán, làm tràn bộ nhớ GPU và gây lỗi logic gradient.

Chạy thử và đọc kết quả

Chạy python autograd_estimation.py, bạn sẽ thấy đại khái như sau (các con số sẽ lệch chút ít giữa những lần chạy vì trọng số được khởi tạo ngẫu nhiên và dữ liệu có nhiễu):

Terminal
=== Fitting a cubic to sin(x) with PyTorch Autograd ===
Initial weights: w1=-0.6197, w2=-0.6255, w3=-0.5777, b=-1.0142

Epoch  200 | Loss: 1255.4491
Epoch  400 | Loss: 586.2173
Epoch  600 | Loss: 282.7377
Epoch  800 | Loss: 144.6042
Epoch 1000 | Loss: 81.4823
Epoch 1200 | Loss: 52.5188
Epoch 1400 | Loss: 39.1717
Epoch 1600 | Loss: 32.9937
Epoch 1800 | Loss: 30.1211
Epoch 2000 | Loss: 28.7794

=== Result ===
Learned polynomial: y_pred = 0.8384*x + 0.0048*x^2 + -0.0907*x^3 + -0.0326
Target function:    y = sin(x)
Final mean squared error: 0.014387

Có ba điều đáng đọc kỹ trong kết quả này, và cả ba đều nói lên điều gì đó về cách mô hình học.

Thứ nhất, sai số giảm nhanh rồi chậm dần. Từ epoch 200 đến 400, loss giảm hơn một nửa (1.255 → 586). Từ epoch 1800 đến 2000, nó chỉ giảm từ 30,1 xuống 28,8. Đó là hình dạng điển hình của gradient descent: khi còn ở xa đáy thì độ dốc lớn nên mỗi bước đi được nhiều; càng gần đáy độ dốc càng thoải, bước đi càng ngắn lại. Loss vẫn đang giảm ở epoch 2000 — mô hình chưa hội tụ hẳn, và nếu bạn tăng số epoch nó sẽ còn tốt lên chút nữa.

Thứ hai, $w_2$ và $b$ đều gần bằng 0, còn $w_1$ và $w_3$ thì không. Đây không phải ngẫu nhiên mà là mô hình đang khám phá ra một tính chất toán học của hàm đích. Hàm $\sin$ là hàm lẻ: $\sin(-x) = -\sin(x)$, tức đồ thị đối xứng qua gốc toạ độ. Các số hạng bậc lẻ ($x$ và $x^3$) cũng có tính chất đó, còn số hạng bậc chẵn ($x^2$) và hằng số thì không. Muốn khớp một hàm lẻ, cách duy nhất là dập tắt hai thành phần chẵn — và đó chính xác là điều Autograd tự tìm ra sau 2.000 vòng, mà không ai nói cho nó biết $\sin$ là hàm lẻ. Chạy lại nhiều lần bạn sẽ thấy $w_2$ luôn quanh $\pm 0{,}01$ trong khi $w_3$ luôn quanh $-0{,}09$.

Thứ ba, sai số cuối 0,0144 là con số tốt hay xấu? Hãy so với mức nhiễu ta tự thêm vào dữ liệu: 0.1 * torch.randn(...), tức độ lệch chuẩn 0,1, tương ứng phương sai $0{,}1^2 = 0{,}01$. Nói cách khác, ngay cả một mô hình hoàn hảo biết chính xác $\sin(x)$ cũng không thể đạt sai số dưới ~0,01 trên tập dữ liệu này, vì phần nhiễu là không thể đoán được. Ta đạt 0,0144 — chỉ hơn giới hạn lý thuyết một chút. Đa thức bậc 3 đã học gần hết những gì có thể học.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt được:
  • Đạt được: Hiểu bản chất cấu trúc dữ liệu Tensor của PyTorch, cách chọn thiết bị tính toán bằng .to(device), và vì sao Tensor CPU dùng chung vùng nhớ với mảng NumPy.
  • Đạt được: Biến đổi hình dạng dữ liệu bằng .view() / .reshape(), hiểu tính liên tục bộ nhớ (contiguity) và biết vì sao chuyển vị lại làm .view() báo lỗi.
  • Đạt được: Nắm vững cơ chế đồ thị tính toán (Computation Graph) và tính đạo hàm tự động Autograd để lan truyền lỗi — kể cả ba cạm bẫy đi kèm: gradient cộng dồn, .backward() chỉ nhận số vô hướng, và cập nhật trọng số phải nằm trong torch.no_grad().
  • Đạt được: Tự viết trọn một vòng huấn luyện bằng tay và đọc được kết quả của nó — biết vì sao $w_2$ hội tụ về 0, và vì sao sai số 0,0144 đã là gần mức tốt nhất có thể trên dữ liệu có nhiễu 0,1.

Cầu nối bài tiếp theo: Từ các viên gạch Tensor đơn lẻ này, ở Bài số 5 chúng ta sẽ chính thức lắp ráp chúng lại để tạo nên mô hình Mạng Nơ-ron đa lớp đầu tiên (MLP) hoàn chỉnh.

Tải file code thực hành minh họa bài học

File Python autograd_estimation.py — mã nguồn tối ưu hóa đa thức bậc 3 để khớp đồ thị đường hình Sin sử dụng động cơ đạo hàm tự động Autograd của PyTorch (chạy python autograd_estimation.py, yêu cầu cài đặt thư viện torch):

Tải về autograd_estimation.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 3: Làm việc với Dữ liệu lớn: NumPy & Pandas chuyên sâu Bài 5: Mạng Nơ-ron Đơn Giản (Perceptron & MLP) Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận