Các ví dụ dưới đây đi từ một đường thẳng tính được bằng tay tới một model có hai feature. Ba việc cần nắm là: tìm hệ số, kiểm cách tính lỗi, và đọc dự đoán theo đúng đơn vị.
Ví dụ 1: Ước lượng tiền thuê xe theo số giờ
Ba hóa đơn ghi số giờ thuê là [1, 2, 3], và tiền thuê là [50, 70, 90] nghìn đồng. Ta muốn biết phí ban đầu và phí mỗi giờ là bao nhiêu, rồi dự đoán tiền thuê cho 2.5 giờ.
import numpy as np
hours = np.array([1., 2., 3.])
cost = np.array([50., 70., 90.])
# Cột toàn số 1 đại diện cho phí ban đầu. Thứ tự tham số là (phí mỗi giờ, phí ban đầu).
A = np.column_stack([hours, np.ones(len(hours))])
theta, _, rank, _ = np.linalg.lstsq(A, cost, rcond=None)
prediction = A @ theta
residual = prediction - cost
print("hệ số:", theta.round(3).tolist())
print("dự đoán train:", prediction.round(3).tolist())
print("2.5 giờ:", round(float(np.array([2.5, 1.]) @ theta), 3))
print("hạng / số cột:", rank, "/", A.shape[1])
assert np.allclose(theta, [20., 30.])
assert np.allclose(A.T @ residual, 0.)
hệ số: [20.0, 30.0]
dự đoán train: [50.0, 70.0, 90.0]
2.5 giờ: 80.0
hạng / số cột: 2 / 2
Hệ số 20 có đơn vị nghìn đồng mỗi giờ, còn 30 là phí cố định. Hóa đơn 2.5 giờ nằm giữa các mức giờ đã thấy, nên con số 80 là nội suy theo đường thẳng đã học. Dòng assert cuối kiểm điều kiện của nghiệm least squares: phần dư vuông góc với mọi cột của A. Khớp đúng ba hóa đơn chỉ chứng tỏ công thức đúng trên dữ liệu này, chưa chứng minh rằng mọi hóa đơn ngoài đời đều tuyến tính.
Giờ thử thêm một cột tính bằng phút. Cột này không mang thêm thông tin nào, vì nó đúng bằng 60 lần cột giờ. lstsq vẫn giải được, nhưng từng hệ số riêng lẻ không còn được xác định duy nhất nữa.
A_duplicate = np.column_stack([hours, 60 * hours, np.ones(len(hours))])
theta_duplicate, _, rank_duplicate, _ = np.linalg.lstsq(
A_duplicate, cost, rcond=None
)
print("hạng / số cột:", rank_duplicate, "/", A_duplicate.shape[1])
print("hệ số giờ + 60 × hệ số phút:",
round(float(theta_duplicate[0] + 60 * theta_duplicate[1]), 3))
print("dự đoán vẫn khớp:", np.allclose(A_duplicate @ theta_duplicate, cost))
assert rank_duplicate < A_duplicate.shape[1]
hạng / số cột: 2 / 3
hệ số giờ + 60 × hệ số phút: 20.0
dự đoán vẫn khớp: True
Ma trận có ba cột nhưng hạng chỉ là 2, dấu hiệu có một cột thừa. Tổng ảnh hưởng theo thời gian vẫn bằng 20, nhưng chia cho cột giờ bao nhiêu, cột phút bao nhiêu thì có vô số cách. Vì vậy dự đoán đúng chưa đủ để đọc mỗi hệ số như một ảnh hưởng riêng. Giải bằng least squares cũng giúp tránh phải nghịch đảo một ma trận suy biến.
Ví dụ 2: Một bước học làm tham số thay đổi thế nào?
Dùng dữ liệu gọn hơn: số giờ [1, 2, 3], tiền thuê [2, 3, 5] đơn vị. Bắt đầu với mọi hệ số bằng 0 để nhìn rõ hướng của gradient. Sau đó so hai learning rate trên cùng một bài toán.
import numpy as np
x = np.array([1., 2., 3.])
y = np.array([2., 3., 5.])
A = np.column_stack([x, np.ones(len(x))])
def loss(theta):
pred = A @ theta
assert pred.shape == y.shape # Chặn lỗi broadcasting tạo ra ma trận lỗi n×n.
return np.mean((pred - y) ** 2)
theta = np.zeros(2)
gradient = 2 * A.T @ (A @ theta - y) / len(y)
print("gradient:", gradient.round(4).tolist())
print("loss trước:", round(float(loss(theta)), 4))
theta -= 0.1 * gradient
print("tham số sau 1 bước:", theta.round(4).tolist())
print("loss sau:", round(float(loss(theta)), 4))
gradient: [-15.3333, -6.6667]
loss trước: 12.6667
tham số sau 1 bước: [1.5333, 0.6667]
loss sau: 0.2163
Gradient của cả hai tham số đều âm, vì lúc đầu mọi dự đoán đều bằng 0, thấp hơn nhãn. Trừ đi một gradient âm thì hệ số tăng lên, đúng hướng cần đi. Loss giảm từ 12.67 xuống 0.22 cho thấy bước này đi đúng hướng, nhưng chưa có nghĩa là đã tới nghiệm tốt nhất.
Tiếp theo, so hai learning rate. Dòng đầu tính ngưỡng an toàn từ độ cong của bài toán: learning rate vượt ngưỡng này thì gradient descent sẽ phân kỳ.
largest_curvature = np.linalg.eigvalsh(2 * A.T @ A / len(y))[-1]
print("ngưỡng learning rate:", round(float(2 / largest_curvature), 4))
for rate in [0.1, 0.3]:
theta = np.zeros(2)
checkpoints = []
for step in range(31):
if step in [0, 1, 10, 30]:
checkpoints.append(round(float(loss(theta)), 4))
theta -= rate * 2 * A.T @ (A @ theta - y) / len(y)
print("lr =", rate, "loss ở bước 0,1,10,30:", checkpoints)
ngưỡng learning rate: 0.1803
lr = 0.1 loss ở bước 0,1,10,30: [12.6667, 0.2163, 0.0622, 0.058]
lr = 0.3 loss ở bước 0,1,10,30: [12.6667, 68.3467, 275171155.1882, 1.3123263234155473e+23]
Learning rate 0.3 vượt ngưỡng 0.18, nên mỗi bước lại nhảy quá đà sang phía bên kia, và loss tăng vọt tới cỡ 10^23 chỉ sau 30 bước. Learning rate 0.1 nằm dưới ngưỡng, nên loss giảm đều. Ngưỡng này không cố định: đổi số cột hay thang đo của feature thì độ cong đổi, và ngưỡng cũng đổi theo. Đó là một lý do nữa để chuẩn hóa feature trước khi chạy gradient descent.
Ví dụ 3: Định giá căn hộ, và kiểm phần dư
Mỗi hàng chứa diện tích tính bằng m² và khoảng cách tới trung tâm tính bằng km, còn target là giá căn hộ tính bằng tỷ đồng. Dữ liệu được tạo ra để luyện quy trình, theo quy luật 0.04 × diện tích − 0.10 × khoảng cách + 0.7 cộng với nhiễu nhỏ. Ta chia dữ liệu trước khi fit, rồi so model với baseline đoán bằng trung bình train.
import numpy as np
from sklearn.dummy import DummyRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(21)
area = rng.uniform(40, 100, 80)
distance = rng.uniform(1, 12, 80)
X = np.column_stack([area, distance])
y = 0.04 * area - 0.10 * distance + 0.7 + rng.normal(0, 0.12, 80)
X_train, X_valid, y_train, y_valid = train_test_split(
X, y, test_size=0.25, random_state=4
)
baseline = DummyRegressor(strategy="mean").fit(X_train, y_train)
model = LinearRegression().fit(X_train, y_train)
pred = model.predict(X_valid)
print("hệ số (diện tích, khoảng cách):", model.coef_.round(4).tolist())
print("intercept:", round(float(model.intercept_), 4))
print("MAE baseline / mô hình:",
round(mean_absolute_error(y_valid, baseline.predict(X_valid)), 4),
round(mean_absolute_error(y_valid, pred), 4))
print("(thật, dự đoán, phần dư) 4 hàng:",
np.column_stack([y_valid, pred, pred - y_valid])[:4].round(3).tolist())
hệ số (diện tích, khoảng cách): [0.0403, -0.0966]
intercept: 0.6293
MAE baseline / mô hình: 0.5854 0.0886
(thật, dự đoán, phần dư) 4 hàng: [[3.3, 3.06, -0.24], [4.066, 3.907, -0.159], [3.391, 3.576, 0.185], [2.164, 2.154, -0.01]]
Hệ số 0.0403 rất gần với 0.04 trong công thức sinh dữ liệu. Đọc thành lời: giữ nguyên khoảng cách, cứ tăng 1 m² thì giá dự đoán tăng khoảng 0.04 tỷ, tức 40 triệu đồng. MAE giảm từ 0.5854 của baseline xuống còn 0.0886 tỷ, nên feature thật sự có ích. Phần dư ở hàng đầu là −0.24, nghĩa là model đoán thấp hơn giá thật 0.24 tỷ. In vài hàng như vậy giúp kiểm dấu của phần dư và việc ghép đúng cặp nhãn với dự đoán, nhưng bốn hàng thì chưa đủ để kết luận phần dư không còn quy luật nào.
Bây giờ đổi khoảng cách sang mét rồi fit lại, để xem hệ số mang ý nghĩa gì. Với least squares không có phạt, đổi đơn vị chỉ đổi cách viết model, không đổi dự đoán.
unit_factor = np.array([1., 1000.])
model_m = LinearRegression().fit(X_train * unit_factor, y_train)
print("hệ số mỗi mét:", round(float(model_m.coef_[1]), 7))
print("hệ số quy về mỗi km:", round(float(model_m.coef_[1] * 1000), 4))
print("dự đoán không đổi:",
np.allclose(pred, model_m.predict(X_valid * unit_factor)))
queries = np.array([[70., 5.], [140., 5.]])
print("căn 70 và 140 m², cùng cách 5 km:", model.predict(queries).round(3).tolist())
assert np.allclose(pred, model_m.predict(X_valid * unit_factor))
hệ số mỗi mét: -9.66e-05
hệ số quy về mỗi km: -0.0966
dự đoán không đổi: True
căn 70 và 140 m², cùng cách 5 km: [2.97, 5.795]
Hệ số theo mét nhỏ hơn hệ số theo km đúng 1000 lần, còn dự đoán thì giữ nguyên. Độ lớn của một hệ số vì vậy phụ thuộc đơn vị, không nên so độ lớn hệ số giữa các cột có đơn vị khác nhau. Căn 140 m² nằm ngoài khoảng 40 đến 100 m² của dữ liệu, nên dự đoán 5.795 tỷ là ngoại suy, dựa hoàn toàn vào giả định đường thẳng còn đúng ở đó. Không có dữ liệu thật ở vùng ấy thì không kiểm được con số này đúng hay sai.