乳癌良性 / 惡性分類
以下以 **乳癌良性/惡性分類** 為例,使用 scikit-learn 內建的 Breast Cancer Wisconsin 資料集,利用 PyTorch 建立一個多層感知器(MLP)進行二元分類。
> 注意:此案例僅用於示範機器學習流程,不應直接作為醫療診斷工具。
---
## 1. 案例與資料
資料集包含:
- 569 筆樣本
- 30 個數值特徵
- 分類目標:
- 0:良性
- 1:惡性
原始資料集的標籤為:
- `0`:malignant
- `1`:benign
為了讓「1」代表惡性,以下程式會重新轉換標籤。
---
## 2. 完整 PyTorch 程式碼
```python
import copy
import random
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (
accuracy_score,
precision_score,
recall_score,
f1_score,
roc_auc_score,
confusion_matrix
)
# --------------------------------------------------
# 1. 設定隨機種子,讓結果較容易重現
# --------------------------------------------------
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(SEED)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("使用裝置:", device)
# --------------------------------------------------
# 2. 載入資料
# --------------------------------------------------
data = load_breast_cancer()
X = data.data.astype(np.float32)
# 原始資料:0 = 惡性,1 = 良性
# 重新定義為:1 = 惡性,0 = 良性
y = (data.target == 0).astype(np.float32)
print("特徵數量:", X.shape[1])
print("樣本數量:", X.shape[0])
# --------------------------------------------------
# 3. 切分訓練集、驗證集、測試集
# --------------------------------------------------
X_train_val, X_test, y_train_val, y_test = train_test_split(
X,
y,
test_size=0.2,
random_state=SEED,
stratify=y
)
X_train, X_val, y_train, y_val = train_test_split(
X_train_val,
y_train_val,
test_size=0.2,
random_state=SEED,
stratify=y_train_val
)
# --------------------------------------------------
# 4. 特徵標準化
# 只能使用訓練集 fit,避免資料洩漏
# --------------------------------------------------
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_val = scaler.transform(X_val)
X_test = scaler.transform(X_test)
# --------------------------------------------------
# 5. 建立 DataLoader
# --------------------------------------------------
def create_dataloader(X, y, batch_size=32, shuffle=False):
X_tensor = torch.tensor(X, dtype=torch.float32)
y_tensor = torch.tensor(y, dtype=torch.float32).view(-1, 1)
dataset = TensorDataset(X_tensor, y_tensor)
return DataLoader(
dataset,
batch_size=batch_size,
shuffle=shuffle
)
train_loader = create_dataloader(
X_train, y_train, batch_size=32, shuffle=True
)
val_loader = create_dataloader(
X_val, y_val, batch_size=32, shuffle=False
)
test_loader = create_dataloader(
X_test, y_test, batch_size=32, shuffle=False
)
# --------------------------------------------------
# 6. 建立二元分類模型
# --------------------------------------------------
class BinaryClassifier(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.network = nn.Sequential(
nn.Linear(input_dim, 32),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(32, 16),
nn.ReLU(),
nn.Dropout(0.2),
# 輸出一個 logit
nn.Linear(16, 1)
)
def forward(self, x):
return self.network(x)
model = BinaryClassifier(input_dim=X_train.shape[1]).to(device)
# --------------------------------------------------
# 7. 損失函數與最佳化器
# --------------------------------------------------
# BCEWithLogitsLoss 內部會自動處理 sigmoid,
# 數值穩定性比 Sigmoid + BCELoss 更好
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(
model.parameters(),
lr=0.001,
weight_decay=1e-4
)
# --------------------------------------------------
# 8. 評估函式
# --------------------------------------------------
def evaluate(model, dataloader, criterion, device):
model.eval()
total_loss = 0.0
all_labels = []
all_probs = []
with torch.no_grad():
for features, labels in dataloader:
features = features.to(device)
labels = labels.to(device)
logits = model(features)
loss = criterion(logits, labels)
total_loss += loss.item() * features.size(0)
# logits 經 sigmoid 後才是機率
probs = torch.sigmoid(logits)
all_labels.extend(labels.cpu().numpy().ravel())
all_probs.extend(probs.cpu().numpy().ravel())
avg_loss = total_loss / len(dataloader.dataset)
all_labels = np.array(all_labels)
all_probs = np.array(all_probs)
all_preds = (all_probs >= 0.5).astype(int)
metrics = {
"loss": avg_loss,
"accuracy": accuracy_score(all_labels, all_preds),
"precision": precision_score(
all_labels, all_preds, zero_division=0
),
"recall": recall_score(
all_labels, all_preds, zero_division=0
),
"f1": f1_score(
all_labels, all_preds, zero_division=0
),
"auc": roc_auc_score(all_labels, all_probs)
}
return metrics
# --------------------------------------------------
# 9. 訓練模型
# --------------------------------------------------
epochs = 100
best_val_auc = 0.0
best_model_state = None
for epoch in range(1, epochs + 1):
model.train()
total_train_loss = 0.0
for features, labels in train_loader:
features = features.to(device)
labels = labels.to(device)
# 清除上一個 batch 的梯度
optimizer.zero_grad()
# 前向傳播
logits = model(features)
# 計算損失
loss = criterion(logits, labels)
# 反向傳播
loss.backward()
# 更新模型參數
optimizer.step()
total_train_loss += loss.item() * features.size(0)
train_loss = total_train_loss / len(train_loader.dataset)
val_metrics = evaluate(
model,
val_loader,
criterion,
device
)
# 儲存驗證集 AUC 最好的模型
if val_metrics["auc"] > best_val_auc:
best_val_auc = val_metrics["auc"]
best_model_state = copy.deepcopy(model.state_dict())
if epoch == 1 or epoch % 10 == 0:
print(
f"Epoch [{epoch:03d}/{epochs}] "
f"Train Loss: {train_loss:.4f} | "
f"Val Loss: {val_metrics['loss']:.4f} | "
f"Val Acc: {val_metrics['accuracy']:.4f} | "
f"Val AUC: {val_metrics['auc']:.4f}"
)
# --------------------------------------------------
# 10. 載入驗證集表現最好的模型
# --------------------------------------------------
model.load_state_dict(best_model_state)
# --------------------------------------------------
# 11. 在測試集上評估
# --------------------------------------------------
test_metrics = evaluate(
model,
test_loader,
criterion,
device
)
print("\n測試集結果")
print("-------------------------")
for name, value in test_metrics.items():
print(f"{name:10s}: {value:.4f}")
# 另外顯示混淆矩陣
model.eval()
all_labels = []
all_probs = []
with torch.no_grad():
for features, labels in test_loader:
features = features.to(device)
logits = model(features)
probs = torch.sigmoid(logits)
all_labels.extend(labels.numpy().ravel())
all_probs.extend(probs.cpu().numpy().ravel())
all_labels = np.array(all_labels).astype(int)
all_preds = (np.array(all_probs) >= 0.5).astype(int)
print("\n混淆矩陣")
print(confusion_matrix(all_labels, all_preds))
```
---
## 3. 關鍵步驟說明
### 3.1 資料切分
資料被分為:
- 訓練集:用來更新模型參數
- 驗證集:用來選擇最佳模型與調整超參數
- 測試集:最後才使用,用來估計模型的泛化能力
使用 `stratify=y` 可以讓各資料集中的良性/惡性比例大致一致,避免切分後類別比例失衡。
---
### 3.2 特徵標準化
不同特徵可能具有不同量級,例如:
- 特徵 A 範圍為 0~1
- 特徵 B 範圍為 0~10,000
若不標準化,神經網路訓練可能較不穩定。因此使用:
```python
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_val = scaler.transform(X_val)
X_test = scaler.transform(X_test)
```
重要的是:
```python
scaler.fit_transform(X_train)
```
只能對訓練集執行 `fit`,驗證集與測試集只能使用:
```python
scaler.transform(...)
```
這樣可以避免測試資料資訊洩漏到模型訓練過程。
---
### 3.3 模型結構
本例的模型為:
```text
30 個輸入特徵
↓
Linear(30 → 32)
↓
ReLU
↓
Dropout(0.2)
↓
Linear(32 → 16)
↓
ReLU
↓
Dropout(0.2)
↓
Linear(16 → 1)
```
最後一層輸出的是 **logit**,不是直接的機率。
---
### 3.4 為什麼使用 `BCEWithLogitsLoss`
二元分類常見的損失函數是 Binary Cross Entropy:
```python
nn.BCELoss()
```
但如果自行使用:
```python
sigmoid + BCELoss
```
在數值上可能不如直接使用:
```python
nn.BCEWithLogitsLoss()
```
`BCEWithLogitsLoss` 已經將 sigmoid 與 binary cross entropy 合併,通常具有較好的數值穩定性。
因此模型的 `forward()` 不需要加入 sigmoid:
```python
logits = model(features)
loss = criterion(logits, labels)
```
只有在取得預測機率時,才使用:
```python
probs = torch.sigmoid(logits)
```
---
### 3.5 二元分類預測方式
本例使用 0.5 作為分類門檻:
```python
preds = (probs >= 0.5).astype(int)
```
也就是:
- 預測機率 ≥ 0.5:判定為惡性
- 預測機率 < 0.5:判定為良性
在醫療或高風險情境中,門檻不一定要固定為 0.5。若希望降低漏診,可以降低門檻,例如設定為 0.3,以提高 Recall,但可能增加誤報。
---
## 4. 評估指標
程式計算以下指標:
### Accuracy
整體預測正確的比例:
```text
正確預測數 / 總樣本數
```
適合類別比例較平衡的情況。
### Precision
被模型預測為惡性的樣本中,實際為惡性的比例:
```text
TP / (TP + FP)
```
### Recall
所有實際為惡性的樣本中,成功被模型找出的比例:
```text
TP / (TP + FN)
```
在癌症篩檢中,Recall 通常相當重要,因為漏掉惡性病例的代價可能較高。
### F1-score
Precision 與 Recall 的調和平均:
```text
2 × Precision × Recall / (Precision + Recall)
```
適合在 Precision 與 Recall 都重要時使用。
### ROC-AUC
衡量模型區分正負類別的能力,且不依賴單一分類門檻。通常:
- 0.5:接近隨機猜測
- 0.7 以上:有一定區分能力
- 0.8 以上:通常算不錯
- 1.0:完美區分
---
## 5. 主要訓練設定
本例的設定如下:
| 設定 | 值 |
|---|---:|
| Batch size | 32 |
| Epochs | 100 |
| Optimizer | Adam |
| Learning rate | 0.001 |
| Weight decay | 0.0001 |
| 隱藏層 | 32、16 |
| Dropout | 0.2 |
| 分類門檻 | 0.5 |
| 損失函數 | BCEWithLogitsLoss |
訓練過程中會根據驗證集的 AUC 儲存最佳模型,而不是直接使用最後一個 epoch 的模型。這可以降低模型過度擬合的風險。
---
## 6. 實務上可進一步改善的方向
1. **加入 Early Stopping**
若驗證集表現連續多個 epoch 沒有改善,就提早停止訓練。
2. **調整分類門檻**
根據實際需求在 Precision 與 Recall 之間取捨。
3. **處理類別不平衡**
可以在 `BCEWithLogitsLoss` 中使用 `pos_weight`。
4. **使用交叉驗證**
當資料量較少時,可以用 K-fold cross-validation 評估模型穩定性。
5. **保存模型與標準化器**
```python
torch.save(model.state_dict(), "binary_classifier.pt")
```
同時也應保存 `scaler`,以確保日後新資料使用相同的標準化方式。
相關學習地圖、教學課程
Python 人工智慧