Chuyển tới nội dung chính

Python cơ bản cho AI

Mọi thuật toán trong các phần sau, từ tìm kiếm trong không gian trạng thái tới học máy và mạng nơ-ron, đều được thực hành bằng Python. Bài này không dạy Python từ con số không mà ôn nhanh đúng những cấu trúc ta sẽ dùng đi dùng lại: biến, danh sách, từ điển, vòng lặp, hàm, rồi khép lại bằng thư viện numpy, công cụ làm việc với vectơ và ma trận làm nền cho cả hồi quy tuyến tính lẫn nơ-ron. Mục tiêu không phải học thuộc cú pháp mà là có đủ vốn để đọc và viết được các đoạn mã thuật toán xuất hiện về sau.

Vì sao chọn Python cho AI

Python trở thành ngôn ngữ mặc định của cộng đồng AI vì ba lý do thực tế. Thứ nhất, cú pháp gần với mã giả: một thuật toán viết trên giấy thường chỉ cần đổi rất ít là chạy được, nên ta tập trung vào ý tưởng thay vì vật lộn với ngôn ngữ. Thứ hai, hệ sinh thái thư viện khoa học cực kỳ phong phú: numpy cho mảng số, pandas cho bảng dữ liệu, matplotlib cho biểu đồ, scikit-learn cho học máy cổ điển, PyTorch và TensorFlow cho học sâu. Thứ ba, đây là chuẩn de facto: phần lớn mã nguồn nghiên cứu, tài liệu và ví dụ trên mạng đều viết bằng Python, nên học nó giúp bạn đọc được công trình của người khác.

Cái giá phải trả là Python thuần chạy chậm. May mắn là các thư viện nặng như numpy thực ra gọi xuống mã C đã tối ưu, nên chỉ cần viết phép toán theo kiểu vectơ (cả mảng một lúc) thay vì lặp từng phần tử là tốc độ vẫn rất tốt. Đây là một thói quen ta sẽ rèn ngay trong bài này.

Thư viện Python thường dùng trong AI
Cách chạy thử mã

Mọi đoạn mã trong bài đều chạy được. Bạn có thể dán vào một tệp .py rồi chạy bằng python ten_tep.py, hoặc gõ trong một notebook. Hãy tự gõ lại và sửa thử các con số, vì học lập trình là môn thực hành chứ không phải môn đọc.

Thử ngay: phát tán (broadcasting) trong numpy

Phát tán là quy tắc cho phép numpy thực hiện một phép toán giữa hai mảng khác hình dạng, chẳng hạn cộng một vectơ vào từng hàng của ma trận, bằng cách nới ảo mảng nhỏ hơn cho khớp mảng lớn thay vì bắt bạn viết vòng lặp. Công cụ dưới đây cho bạn thử phát tán từng bước. Chọn hình dạng cho operand, sửa giá trị của nó theo ý bạn, xem numpy so khớp chiều từ phải sang trái, vectơ được nới ảo cho khớp ma trận, rồi kết quả điền vào từng ô. Có cả những hình dạng không hợp lệ để thấy lỗi hình dạng khi các chiều không khớp.

Phát tán chạy từng bướcÔ 1/9
So khớp chiều từ phải sang trái
Ma trận
3
3
Operand
1
3
Hợp lệ?
OK
OK
Hai chiều tương thích khi bằng nhau, hoặc một trong hai bằng 1. Dấu gạch nghĩa là chiều thiếu, numpy coi như 1.
Ma trận A (3x3)
1
2
3
4
5
6
7
8
9
+
Operand (1,3)
được nới ảo thành 3x3
Sửa được: gõ giá trị khác để tính lại.
=
Kết quả
11
·
·
·
·
·
·
·
·
Ô (0, 0): 1 + 10 = 11. Giá trị 10 lấy từ operand sau khi nới ảo cho khớp vị trí này.
  • Phát tán so khớp các chiều từ phải sang trái: bằng nhau hoặc bằng 1 thì hợp lệ.
  • Vectơ dùng chung được lặp lại theo chiều bằng 1, không thực sự sao chép bộ nhớ.
  • Khi chiều cuối không khớp và không chiều nào bằng 1, numpy báo lỗi không khớp chiều.

Biến và kiểu dữ liệu cơ bản

Trong Python, biến chỉ là một cái tên gắn vào một giá trị; bạn không cần khai báo kiểu trước, kiểu được suy ra từ giá trị gán vào. Các kiểu nền tảng gồm số nguyên int, số thực float, chuỗi str, và giá trị luận lý bool nhận một trong hai trị True hoặc False.

# A name is just a label bound to a value; no type declaration needed
n_states = 8 # int
learning_rate = 0.01 # float
agent_name = "Vacuum" # str
is_goal = False # bool

# Python infers the type; check it with type()
print(type(n_states), type(learning_rate)) # <class 'int'> <class 'float'>

# Arithmetic and comparison produce new values
total = n_states * 2 + 1
reached = total > 10 # bool result: True
print(total, reached) # 17 True

Một điểm cần chú ý về phép so sánh: kết quả của total > 10 là một giá trị bool. Các phép so sánh như lớn hơn hay nhỏ hơn xuất hiện liên tục trong điều kiện dừng của thuật toán, nên hãy quen với việc chúng trả về True hoặc False.

Danh sách và vòng lặp

Danh sách (list) là dãy có thứ tự và thay đổi được, dùng để lưu một chuỗi phần tử. Trong các thuật toán tìm kiếm, ta dùng danh sách để lưu biên tìm kiếm (frontier), tức tập các trạng thái chờ xét. Phần tử được đánh chỉ số từ 0.

# A list stores an ordered, mutable sequence
frontier = ["A", "B", "C"]
print(frontier[0]) # first element: A
print(frontier[-1]) # last element: C

frontier.append("D") # add to the end -> ["A", "B", "C", "D"]
node = frontier.pop(0) # remove and return the front -> "A"
print(node, frontier) # A ["B", "C", "D"]

# Slicing takes a sub-range; indices go from start up to (but not including) stop
print(frontier[0:2]) # ["B", "C"]

Cặp thao tác append thêm vào cuối và pop(0) lấy ra từ đầu cho ta hành vi hàng đợi (queue), nền tảng của tìm kiếm theo chiều rộng mà ta sẽ gặp sau. Nếu thay pop(0) bằng pop() (lấy từ cuối) thì danh sách hoạt động như ngăn xếp (stack), nền tảng của tìm kiếm theo chiều sâu.

Vòng lặp for duyệt qua từng phần tử của một dãy. Đây là cách phổ biến nhất để xử lý mọi phần tử trong danh sách.

# Iterate over every element
scores = [3, 1, 4, 1, 5]
total = 0
for s in scores:
total += s # accumulate the sum
print(total) # 14

# enumerate gives both index and value when you need the position
for i, s in enumerate(scores):
print("position", i, "holds", s)

# range(n) yields 0, 1, ..., n-1; handy for counted loops
for k in range(3):
print("step", k) # step 0 / step 1 / step 2

Một cách viết gọn rất hay dùng trong Python là cách hiểu danh sách (list comprehension): tạo một danh sách mới từ một dãy có sẵn chỉ trong một dòng. Nó vừa ngắn vừa dễ đọc khi quen mắt.

# List comprehension: build a new list in one line
squares = [s * s for s in scores] # [9, 1, 16, 1, 25]

# With a filter condition at the end
big = [s for s in scores if s >= 4] # [4, 5]
print(squares, big)

Từ điển

Từ điển (dict) ánh xạ khóa tới giá trị, cho phép tra cứu nhanh theo khóa thay vì theo vị trí. Nó cực kỳ hợp để lưu một đồ thị dưới dạng danh sách kề: mỗi đỉnh là một khóa, giá trị là danh sách các đỉnh kề.

# A dictionary maps keys to values; here an adjacency list of a graph
graph = {
"A": ["B", "C"],
"B": ["D"],
"C": ["D"],
"D": [],
}

print(graph["A"]) # neighbours of A: ["B", "C"]
graph["E"] = ["A"] # add a new key-value pair

# .get returns a default instead of raising an error if the key is missing
print(graph.get("Z", [])) # [] because "Z" is not present

# Iterate over keys and values together with .items()
for node, neighbours in graph.items():
print(node, "connects to", neighbours)

Phương thức .get(key, default) đáng nhớ vì nó tránh lỗi khi khóa không tồn tại: thay vì làm chương trình dừng, nó trả về giá trị mặc định. Mẹo này dùng nhiều khi đếm tần suất hay tra bảng heuristic mà không chắc khóa đã có sẵn.

Giống danh sách, từ điển cũng có cách hiểu (dict comprehension) để dựng nhanh một bảng tra từ dữ liệu.

# Dictionary comprehension: a small table built from data
out_degree = {node: len(neighbours) for node, neighbours in graph.items()}
print(out_degree) # {"A": 2, "B": 1, "C": 1, "D": 0, "E": 1}
Khi nào dùng list, khi nào dùng dict

Dùng danh sách khi thứ tự quan trọng và bạn truy cập theo vị trí hoặc duyệt tuần tự. Dùng từ điển khi bạn truy cập theo một khóa có ý nghĩa (tên đỉnh, nhãn lớp, từ vựng) và cần tra cứu nhanh. Trong tìm kiếm trên đồ thị thưa, từ điển kề tiết kiệm bộ nhớ hơn nhiều so với ma trận kề.

Hàm

Hàm đóng gói một đoạn xử lý có thể tái dùng, nhận đầu vào qua tham số và trả kết quả qua return. Trong giáo trình, hầu hết thuật toán đều được viết thành hàm để gọi lại nhiều lần với dữ liệu khác nhau.

def is_goal(state, goal="D"):
"""Return True if the state is the goal state."""
return state == goal

def path_cost(edges, weight):
"""Sum the weight of each edge along a path."""
total = 0
for edge in edges:
total += weight.get(edge, 1) # default weight 1 if missing
return total

# Recursion: a function calling itself, mirroring how a tree expands
def factorial(n):
if n <= 1: # base case stops the recursion
return 1
return n * factorial(n - 1) # recursive step

print(is_goal("D")) # True
print(factorial(5)) # 120

Tham số goal="D" là một tham số mặc định: nếu người gọi không truyền giá trị thì Python dùng "D". Điều này giúp lời gọi hàm gọn hơn trong trường hợp thông dụng.

Bẫy tham số mặc định thay đổi được

Tham số mặc định chỉ được tính một lần, đúng lúc định nghĩa hàm, chứ không phải mỗi lần gọi. Vì vậy tuyệt đối không dùng một đối tượng thay đổi được như danh sách rỗng làm giá trị mặc định, chẳng hạn def f(x, acc=[]). Danh sách đó sẽ được dùng chung qua mọi lần gọi và âm thầm tích lũy sai. Cách đúng là để mặc định là None rồi tạo danh sách mới bên trong hàm, như acc = [] khi acc is None.

Giới thiệu numpy

Từ phần học máy trở đi, dữ liệu hầu như luôn ở dạng vectơ và ma trận. Thư viện numpy cung cấp kiểu mảng nhiều chiều (ndarray) cùng các phép toán theo kiểu vectơ, vừa ngắn gọn vừa nhanh hơn vòng lặp Python thuần rất nhiều. Quy ước chung của cả cộng đồng, và của giáo trình này, là nhập thư viện bằng import numpy as np.

Mảng và phép toán theo phần tử

Khác với danh sách, khi bạn áp một phép toán lên mảng numpy thì phép toán đó tác động lên từng phần tử cùng lúc. Đây gọi là vectơ hóa.

import numpy as np

# Create 1-D arrays
x = np.array([1.0, 2.0, 3.0])
w = np.array([0.5, 0.5, 0.5])

# Operations apply element-wise, no explicit loop needed
scaled = 2 * x # array([2., 4., 6.])
shifted = x + 1 # array([2., 3., 4.])
summed = x + w # array([1.5, 2.5, 3.5])
squared = x ** 2 # array([1., 4., 9.])

print(scaled)
print(shifted)
print(summed)
print(squared)

Hãy để ý x + 1: ta cộng một số vô hướng vào cả một mảng. numpy tự động nới số đó ra cho khớp mọi phần tử; cơ chế này gọi là phát tán (broadcasting). Nhờ nó, mã ngắn lại và không cần viết vòng lặp.

Tích vô hướng và tổng có trọng số

Tích vô hướng của hai vectơ là tổng các tích từng cặp phần tử tương ứng. Với hai mảng wx, kết quả là w[0]*x[0] + w[1]*x[1] + .... Trong numpy ta dùng np.dot.

import numpy as np

x = np.array([1.0, 2.0, 3.0])
w = np.array([0.5, 0.5, 0.5])

dot = np.dot(w, x) # 0.5*1 + 0.5*2 + 0.5*3 = 3.0
print(dot) # 3.0

# Equivalent ways to write the same dot product
print(w @ x) # 3.0, the @ operator
print(np.sum(w * x)) # 3.0, element-wise product then sum

Phép np.dot(w, x) chính là tổng có trọng số mà bằng văn xuôi ta viết là sum của w_i * x_i theo mọi i. Đây là viên gạch cốt lõi của cả học máy: một nơ-ron nhân tạo lấy đầu vào x, nhân với trọng số w, cộng lại rồi áp một hàm kích hoạt. Khi tới phần mạng nơ-ron, bạn sẽ thấy đúng phép tính này lặp lại ở mọi tầng.

Nhân ma trận và vectơ

Khi xử lý cả một lô dữ liệu cùng lúc, ta xếp các vectơ thành ma trận rồi nhân một lần thay vì lặp từng dòng. Toán tử @ thực hiện nhân ma trận.

import numpy as np

x = np.array([1.0, 2.0, 3.0])

# A 2x3 matrix: two rows, three columns
M = np.array([[1.0, 0.0, 1.0],
[0.0, 1.0, 1.0]])

print(M.shape) # (2, 3): two rows, three columns

# Matrix-vector product: each row of M dotted with x
y = M @ x # row 0: 1+0+3 = 4 ; row 1: 0+2+3 = 5
print(y) # array([4., 5.])

Thuộc tính M.shape trả về một bộ đôi mô tả số dòng và số cột, ở đây là (2, 3). Việc tính M @ x cho ra một vectơ mà phần tử thứ i là tích vô hướng giữa dòng i của M với x. Khi học máy chạy trên hàng nghìn mẫu, chính phép nhân ma trận này thay cho hàng nghìn vòng lặp, nhanh hơn nhiều lần.

import numpy as np

# A few handy array constructors used throughout the course
zeros = np.zeros(3) # array([0., 0., 0.])
ones = np.ones((2, 2)) # 2x2 matrix of ones
grid = np.arange(0, 10, 2) # array([0, 2, 4, 6, 8])
mean = np.mean(np.array([2, 4, 6])) # 4.0
print(zeros, grid, mean)

Phát tán nhiều chiều

Phát tán không dừng ở việc cộng một số vô hướng với mảng. numpy còn nới được một vectơ ra cho khớp với cả một ma trận, miễn là các chiều tương thích. Một ví dụ hay gặp trong học máy là chuẩn hóa từng cột của một bảng dữ liệu: ta trừ vectơ trung bình của các cột khỏi mọi hàng chỉ bằng một phép trừ, không cần vòng lặp nào.

import numpy as np

# Two samples, three features each
data = np.array([[2.0, 4.0, 6.0],
[4.0, 8.0, 12.0]])

col_mean = data.mean(axis=0) # mean of each column: [3., 6., 9.]

# Subtract the row vector from every row via broadcasting
centered = data - col_mean # each column now has zero mean
print(centered)

Quy tắc phát tán so khớp các chiều từ phải sang trái: hai chiều tương thích khi chúng bằng nhau, hoặc một trong hai bằng 1, khi đó numpy tự lặp lại theo chiều bằng 1. Nắm quy tắc này giúp bạn viết các phép chuẩn hóa và cộng độ lệch trên cả lô dữ liệu một cách gọn gàng, đồng thời hiểu vì sao đôi khi numpy báo lỗi không khớp chiều.

Ngược lại, thói quen lặp từng phần tử của một mảng numpy bằng vòng lặp Python là bẫy hiệu năng thường gặp nhất. Nó chịu hai lần phạt: chi phí của vòng lặp Python thuần, cộng với chi phí bọc mỗi phần tử thành một đối tượng Python ở từng bước. Với mảng lớn, cùng một phép tính viết theo kiểu vectơ có thể nhanh hơn hàng chục tới hàng trăm lần, nên khi thấy mình đang viết vòng lặp trên mảng, hãy dừng lại tìm phép mảng tương đương trước đã.

Cắt lát mảng numpy trả về khung nhìn, không phải bản sao

Một bẫy khiến người mới lúng túng: cắt lát một mảng numpy không tạo ra bản sao dữ liệu mà trả về một khung nhìn (view) dùng chung bộ nhớ với mảng gốc. Vì vậy nếu bạn sửa phần tử trên lát cắt thì mảng gốc cũng đổi theo, khác hẳn hành vi của cắt lát danh sách Python vốn cho ra một bản sao nông. Khi cần một bản độc lập để sửa mà không đụng tới mảng gốc, hãy gọi tường minh phương thức .copy().

Hai ý cốt lõi về numpy

Một là vectơ hóa: viết phép toán trên cả mảng thay vì lặp từng phần tử, mã vừa ngắn vừa chạy nhanh vì numpy hạ xuống mã C tối ưu. Hai là phát tán: numpy tự nới chiều khi cộng một số vô hướng với mảng, như x + 1. Hai cơ chế này xuất hiện ở gần như mọi đoạn mã học máy về sau, nên hãy làm quen ngay từ bây giờ.

Một ví dụ nhỏ ghép mọi thứ lại

Để thấy các mảnh ghép cùng làm việc, hãy viết một nơ-ron đơn giản: nó nhận một vectơ đầu vào, một vectơ trọng số, một độ lệch (bias), tính tổng có trọng số rồi áp hàm ngưỡng để ra quyết định nhị phân. Đây chính là perceptron, viên gạch lịch sử của mạng nơ-ron.

import numpy as np

def perceptron(x, w, b):
"""A single neuron: weighted sum, bias, then a threshold."""
z = np.dot(w, x) + b # weighted sum plus bias
return 1 if z > 0 else 0 # step activation: fire or not

# Try a tiny AND-like decision
w = np.array([1.0, 1.0])
b = -1.5
print(perceptron(np.array([1.0, 1.0]), w, b)) # 1, both inputs on
print(perceptron(np.array([1.0, 0.0]), w, b)) # 0, only one input on
print(perceptron(np.array([0.0, 0.0]), w, b)) # 0, no input

Đoạn mã trên gói lại mọi thứ đã học: một hàm với tham số, mảng numpy, tích vô hướng np.dot, phép so sánh trả về quyết định, và một biểu thức điều kiện gọn. Khi bước vào phần mạng nơ-ron, bạn sẽ chỉ cần mở rộng đúng ý tưởng này thành nhiều nơ-ron xếp tầng.

Bài tập thực hành

Bài 1. Cho danh sách scores = [3, 1, 4, 1, 5, 9, 2, 6]. Dùng cách hiểu danh sách để tạo một danh sách mới chỉ giữ các phần tử chẵn, rồi tính tổng của chúng.

Gợi ý

Một số chẵn khi chia hết cho 2, tức s % 2 == 0. Viết even = [s for s in scores if s % 2 == 0] rồi total = sum(even). Kết quả là [4, 2, 6] và tổng bằng 12.

Bài 2. Viết một hàm vacuum_agent(percept) nhận đầu vào là một bộ đôi gồm vị trí và trạng thái bẩn, ví dụ ("A", "ban"), và trả về một trong ba hành động "hut", "trai", "phai" theo quy tắc: nếu ô hiện tại bẩn thì hút, nếu đang ở ô A thì sang phải, nếu đang ở ô B thì sang trái. Chú thích trong mã bằng tiếng Anh.

Gợi ý

Tách bộ đôi bằng location, status = percept. Kiểm tra status == "ban" trước; nếu đúng thì trả "hut". Ngược lại dựa vào location để chọn "phai" khi ở "A""trai" khi ở "B". Toàn bộ là một chuỗi if / elif / else.

Bài 3. Dùng numpy, viết hàm weighted_sum(w, x) tính tổng có trọng số của hai mảng cùng độ dài mà không dùng vòng lặp Python. Kiểm thử với w = [0.2, 0.3, 0.5]x = [10, 20, 30], đối chiếu kết quả với phép tính tay.

Gợi ý

Thân hàm chỉ cần return np.dot(np.array(w), np.array(x)). Tính tay: 0.2*10 + 0.3*20 + 0.5*30 = 2 + 6 + 15 = 23. Kết quả numpy phải khớp con số 23.

Bài 4. Cho từ điển kề graph trong bài, viết một hàm in ra mọi cạnh của đồ thị dưới dạng cặp chuỗi "u -> v". Giải thích vì sao từ điển là cấu trúc phù hợp để lưu một đồ thị thưa.

Gợi ý

Duyệt for u, neighbours in graph.items() rồi lồng for v in neighbours: print(u, "->", v). Từ điển kề chỉ lưu các cạnh thực sự tồn tại, nên với đồ thị thưa (ít cạnh so với số đỉnh) nó tiết kiệm bộ nhớ hơn nhiều so với ma trận kề vốn phải lưu cả các ô bằng 0.

Bài 5. Mở rộng hàm perceptron ở phần ví dụ: thay hàm ngưỡng cứng bằng hàm sigmoid 1 / (1 + np.exp(-z)) để đầu ra là một số thực trong khoảng từ 0 tới 1 thay vì chỉ 0 hoặc 1. In thử đầu ra với vài đầu vào khác nhau.

Gợi ý

Vẫn tính z = np.dot(w, x) + b như cũ, nhưng return 1 / (1 + np.exp(-z)). Hàm sigmoid biến mọi số thực thành một giá trị giữa 0 và 1, có thể đọc như một xác suất. Đây chính là bước chuyển từ perceptron sang hồi quy logistic mà phần học máy sẽ bàn kỹ.

Tự kiểm tra

Vì sao nên ưu tiên viết phép toán trên cả mảng numpy thay vì lặp từng phần tử bằng Python?

Vì numpy thực thi phép toán vectơ bằng mã C đã tối ưu bên dưới, trong khi vòng lặp Python thuần chạy chậm hơn nhiều bậc. Viết theo kiểu vectơ vừa cho mã ngắn và dễ đọc hơn, vừa nhanh hơn đáng kể khi dữ liệu lớn. Đây là lý do mọi đoạn mã học máy thực tế đều tránh vòng lặp tường minh khi có thể thay bằng phép mảng.

Tích vô hướng np.dot(w, x) tương ứng với phép tính nào trong một nơ-ron?

Nó chính là tổng có trọng số của các đầu vào: mỗi đầu vào x_i được nhân với trọng số w_i rồi cộng tất cả lại. Một nơ-ron lấy tổng này, cộng thêm một độ lệch, rồi áp một hàm kích hoạt để ra quyết định. Vì vậy hiểu rõ tích vô hướng là điều kiện để hiểu cách mạng nơ-ron tính toán ở mọi tầng.

Vì sao không nên dùng danh sách rỗng làm giá trị tham số mặc định của một hàm?

Vì giá trị mặc định chỉ được tạo một lần lúc định nghĩa hàm, không phải mỗi lần gọi. Danh sách đó sẽ bị dùng chung qua mọi lời gọi và âm thầm giữ lại dữ liệu của các lần trước, gây lỗi khó truy. Cách đúng là để mặc định là None rồi tạo một danh sách mới bên trong hàm khi cần.

Câu hỏi tự kiểm

Kiểm tra nhanh: Python cho AI0/4 đúngchưa trả lời
  1. 1Vì sao nên ưu tiên viết phép toán trên cả mảng numpy thay vì lặp từng phần tử bằng Python?
  2. 2Quy tắc phát tán (broadcasting) của numpy so khớp các chiều theo hướng nào?
  3. 3Cắt lát một mảng numpy, ví dụ a[1:3], trả về gì?
  4. 4np.dot(w, x) tương ứng với phép tính nào trong một nơ-ron?
Ghi nhớ

Python được chọn cho AI vì cú pháp gần mã giả, thư viện khoa học phong phú và là chuẩn chung của cộng đồng. Bạn cần thạo bốn cấu trúc nền: danh sách cho dãy có thứ tự, từ điển cho ánh xạ khóa tới giá trị, vòng lặp để duyệt, và hàm để đóng gói thuật toán. numpy đưa thêm mảng nhiều chiều với hai cơ chế then chốt là vectơ hóa và phát tán; trong đó tích vô hướng np.dot và phép nhân ma trận @ là viên gạch tính toán của cả học máy lẫn mạng nơ-ron mà các phần sau sẽ dựng lên.