Spaces:
Sleeping
Sleeping
File size: 16,599 Bytes
6709d22 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 |
"""
流程圖
讀取資料 → 分割資料 → 編碼 → 建立 Dataset / DataLoader
↓
建立模型(BERT+LSTM+CNN)
↓
BERT 輸出 [batch, seq_len, 768]
↓
BiLSTM [batch, seq_len, hidden_dim*2]
↓
CNN 模組 (Conv1D + Dropout + GlobalMaxPooling1D)
↓
Linear 分類器(輸出詐騙機率)
↓
訓練模型(Epochs)
↓
評估模型(Accuracy / F1 / Precision / Recall)
↓
儲存模型(.pth)
"""
#引入重要套件Import Library
import os
import torch # PyTorch 主模組
import torch.nn as nn # 神經網路相關的層(例如 LSTM、Linear)
import pandas as pd
import re
from dotenv import load_dotenv
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader, Dataset # 提供 Dataset、DataLoader 類別
from transformers import BertTokenizer # BertTokenizer把文字句子轉換成 BERT 格式的 token ID,例如 [CLS] 今天 天氣 不錯 [SEP] → [101, 1234, 5678, ...]
from sklearn.model_selection import train_test_split
from transformers import BertModel
"""
# ------------------- 載入 .env 環境變數 -------------------
load_dotenv()
base_dir = os.getenv("DATA_DIR", "./data") # 如果沒設環境變數就預設用 ./data
# ------------------- 使用相對路徑找 CSV -------------------
#,os.path.join(base_dir, "NorANDScamInfo_data1.csv"),os.path.join(base_dir, "ScamInfo_data1.csv"),os.path.join(base_dir, "NormalInfo_data1.csv")
#如有需要訓練複數筆資料可以使用這個方法csv_files = [os.path.join(base_dir, "檔案名稱1.csv"),os.path.join(base_dir, "檔案名稱2.csv")]
#程式碼一至131行
# GPU 記憶體限制(可選)
# os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "max_split_size_mb:16"
#資料前處理
class BertPreprocessor:
def __init__(self, tokenizer_name="ckiplab/bert-base-chinese", max_len=128):
self.tokenizer = BertTokenizer.from_pretrained(tokenizer_name)
self.max_len = max_len
def load_and_clean(self, filepath):
#載入 CSV 並清理 message 欄位。
df = pd.read_csv(filepath)
df = df.dropna().drop_duplicates().reset_index(drop=True)
# 文字清理:移除空白、保留中文英數與標點
df["message"] = df["message"].astype(str)
df["message"] = df["message"].apply(lambda text: re.sub(r"\s+", "", text))
df["message"] = df["message"].apply(lambda text: re.sub(r"[^\u4e00-\u9fffA-Za-z0-9。,!?]", "", text))
return df[["message", "label"]] # 保留必要欄位
def encode(self, messages):
#使用 HuggingFace BERT Tokenizer 將訊息編碼成Bert模型輸入格式。
return self.tokenizer(
list(messages),
return_tensors="pt",
truncation=True,
padding="max_length",
max_length=self.max_len
)
#自動做資料前處理
def build_bert_inputs(files):
#將正常與詐騙資料分別指定 label,統一清理、編碼,回傳模型可用的 input tensors 與 labels。
processor = BertPreprocessor()
dfs = []
# 合併正常 + 詐騙檔案清單
all_files = files
for filepath in all_files:
df = processor.load_and_clean(filepath)
dfs.append(df)
# 合併所有資料。在資料清理過程中dropna():刪除有空值的列,drop_duplicates():刪除重複列,filter()或df[...]做條件過濾,concat():將多個 DataFrame合併
# 這些操作不會自動重排索引,造成索引亂掉。
# 合併後統一編號(常見於多筆資料合併)all_df = pd.concat(dfs, 關鍵-->ignore_index=True)
all_df = pd.concat(dfs, ignore_index=True)
print(f"✅ 已讀入 {len(all_df)} 筆資料")
print(all_df["label"].value_counts())
#製作 train/val 資料集
train_texts, val_texts, train_labels, val_labels = train_test_split(
all_df["message"], all_df["label"],
stratify=all_df["label"],
test_size=0.2,
random_state=25,
shuffle=True
)
# 進行 BERT tokenizer 編碼
train_inputs = processor.encode(train_texts)
val_inputs = processor.encode(val_texts)
return train_inputs, train_labels, val_inputs, val_labels, processor
#定義 PyTorch Dataset 類別。ScamDataset 繼承自 torch.utils.data.Dataset
#將 BERT 輸出的 token 與對應標籤封裝成 PyTorch 能使用的格式
class ScamDataset(Dataset):
def __init__(self, inputs, labels):
self.input_ids = inputs["input_ids"] # input_ids:句子的 token ID;attention_mask:注意力遮罩(0 = padding)
self.attention_mask = inputs["attention_mask"] # token_type_ids:句子的 segment 區分
self.token_type_ids = inputs["token_type_ids"] # torch.tensor(x, dtype=...)將資料(x)轉為Tensor的標準做法。
self.labels = torch.tensor(labels.values, dtype=torch.float32) # x可以是 list、NumPy array、pandas series...
# dtypefloat32:浮點數(常用於 回歸 或 BCELoss 二分類);long:整數(常用於 多分類 搭配 CrossEntropyLoss)。labels.values → 轉為 NumPy array
def __len__(self): # 告訴 PyTorch 這個 Dataset 有幾筆資料
return len(self.labels) # 給 len(dataset) 或 for i in range(len(dataset)) 用的
def __getitem__(self, idx): #每次調用 __getitem__() 回傳一筆 {input_ids, attention_mask, token_type_ids, labels}
return { #DataLoader 每次會呼叫這個方法多次來抓一個 batch 的資料
"input_ids":self.input_ids[idx],
"attention_mask":self.attention_mask[idx],
"token_type_ids":self.token_type_ids[idx],
"labels":self.labels[idx]
}
# 這樣可以同時處理 scam 和 normal 資料,不用重複寫清理與 token 處理
if __name__ == "__main__":
csv_files = [os.path.join(base_dir, "NorANDScamInfo_data3k.csv")]
train_inputs, train_labels, val_inputs, val_labels, processor = build_bert_inputs(csv_files)
train_dataset = ScamDataset(train_inputs, train_labels)
val_dataset = ScamDataset(val_inputs, val_labels)
# batch_size每次送進模型的是 8 筆資料(而不是一筆一筆)
# 每次從 Dataset 中抓一批(batch)資料出來
train_loader = DataLoader(train_dataset, batch_size=8)
val_loader = DataLoader(val_dataset, batch_size=8)
"""
"""
class BertLSTM_CNN_Classifier(nn.Module)表示:你定義了一個子類別,
繼承自 PyTorch 的基礎模型類別 nn.Module。
若你在 __init__() 裡沒有呼叫 super().__init__(),
那麼父類別 nn.Module 的初始化邏輯(包含重要功能)就不會被執行,
導致整個模型運作異常或錯誤。
"""
# nn.Module是PyTorch所有神經網路模型的基礎類別,nn.Module 是 PyTorch 所有神經網路模型的基礎類別
class BertLSTM_CNN_Classifier(nn.Module):
def __init__(self, hidden_dim=128, num_layers=1, dropout=0.3):
# super()是Python提供的一個方法,用來呼叫「父類別的版本」的方法。
# 呼叫:super().__init__()讓父類別(nn.Module)裡面那些功能、屬性都被正確初始化。
# 沒super().__init__(),這些都不會正確運作,模型會壞掉。
# super() 就是 Python 提供給「子類別呼叫父類別方法」的方式
super().__init__()
# 載入中文預訓練的 BERT 模型,輸入為句子token IDs,輸出為每個 token 的向量,大小為 [batch, seq_len, 768]。
self.bert = BertModel.from_pretrained("ckiplab/bert-base-chinese") # 這是引入hugging face中的tranceformat
# 接收BERT的輸出(768 維向量),進行雙向LSTM(BiLSTM)建模,輸出為 [batch, seq_len, hidden_dim*2],例如 [batch, seq_len, 256]
"""
LSTM 接收每個token的768維向量(來自 BERT)作為輸入,
透過每個方向的LSTM壓縮成128維的語意向量。
由於是雙向LSTM,會同時從左到右(前向)和右到左(後向)各做一次,
最後將兩個方向的輸出合併為256維向量(128×2)。
每次處理一個 batch(例如 8 句話),一次走完整個時間序列。
"""
self.LSTM = nn.LSTM(input_size=768,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
bidirectional=True)
# CNN 模組:接在 LSTM 後的輸出上。將LSTM的輸出轉成卷積層格式,適用於Conv1D,CNN可學習位置不變的局部特徵。
self.conv1 = nn.Conv1d(in_channels=hidden_dim*2,
out_channels=128,
kernel_size=3, # 這裡kernel_size=3 為 3-gram 特徵
padding=1)
self.dropout = nn.Dropout(dropout) # 隨機將部分神經元設為 0,用來防止 overfitting。
self.global_maxpool = nn.AdaptiveAvgPool1d(1) #將一整句話的特徵濃縮成一個固定大小的句子表示向量
# 將CNN輸出的128維特徵向量輸出為一個「機率值」(詐騙或非詐騙)。
self.classifier = nn.Linear(128,1)
def forward(self, input_ids, attention_mask, token_type_ids):
#BERT 編碼
outputs = self.bert(input_ids=input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids)
#.last_hidden_state是BertModel.from_pretrained(...)內部的key,會輸出 [batch, seq_len, 768]
hidden_states = outputs.last_hidden_state
# 送入 BiLSTM
# transpose(1, 2) 的用途是:讓 LSTM 輸出的資料形狀符合 CNN 所要求的格式
# 假設你原本 LSTM 輸出是: [batch_size, seq_len, hidden_dim*2] = [8, 128, 256]
# 但CNN(Conv1d)的輸入格式需要是:[batch_size, in_channels, seq_len] = [8, 256, 128]
# 因此你需要做:.transpose(1, 2)把 seq_len 和 hidden_dim*2 調換
LSTM_out, _ = self.LSTM(hidden_states) # [batch, seq_len, hidden_dim*2]
LSTM_out = LSTM_out.transpose(1, 2) # [batch, hidden_dim*2, seq_len]
# 卷積 + Dropout
x = self.conv1(LSTM_out) # [batch, 128, seq_len]
x = self.dropout(x)
#全局池化
# .squeeze(dim) 的作用是:把某個「維度大小為 1」的維度刪掉
# x = self.global_maxpool(x).squeeze(2) # 輸出是 [batch, 128, 1]
# 不 .squeeze(2),你會得到 shape 為 [batch, 128, 1],不方便後面接 Linear。
# .squeeze(2)=拿掉第 2 維(數值是 1) → 讓形狀變成 [batch, 128]
x = self.global_maxpool(x).squeeze(2) # [batch, 128]
#分類 & Sigmoid 機率輸出
logits = self.classifier(x)
#.sigmoid() → 把 logits 轉成 0~1 的機率.squeeze() → 變成一維 [batch] 長度的機率 list
"""例如:
logits = [[0.92], [0.05], [0.88], [0.41], ..., [0.17]]
→ sigmoid → [[0.715], [0.512], ...]
→ squeeze → [0.715, 0.512, ...]
"""
return torch.sigmoid(logits).squeeze() # 最後輸出是一個值介於 0 ~ 1 之間,代表「為詐騙訊息的機率」。
"""
# 設定 GPU 裝置
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 初始化模型
model = BertLSTM_CNN_Classifier().to(device)
# 定義 optimizer 和損失函數
optimizer = torch.optim.Adam(model.parameters(),lr=2e-5)
criterion = nn.BCELoss()
# 本機訓練迴圈,要訓練再取消註解,否則在線上版本一律處於註解狀態
if __name__ == "__main__": # 只有當我「直接執行這個檔案」時,才執行以下訓練程式(不是被別人 import 使用時)。
if os.path.exists("model.pth"):
print("✅ 已找到 model.pth,載入模型跳過訓練")
model.load_state_dict(torch.load("model.pth", map_location=device))
else:
print("🚀 未找到 model.pth,開始訓練模型...")
num_epochs = 15 # batch_size設定在train_loader和test_loader那
for epoch in range(num_epochs):
model.train() # 從nn.Module繼承的方法。將模型設為「訓練模式」,有些層(像 Dropout 或 BatchNorm)會啟用訓練行為。
total_loss = 0.0
for batch in train_loader:
# 清理舊梯度,以免累加。為甚麼要?因為PyTorch 預設每次呼叫 .backward() 都會「累加」梯度(不會自動清掉)
# 沒 .zero_grad(),梯度會越累積越多,模型會亂掉。
optimizer.zero_grad()
input_ids = batch["input_ids"].to(device)
attention_mask = batch["attention_mask"].to(device)
token_type_ids = batch["token_type_ids"].to(device)
labels = batch["labels"].to(device)
outputs = model(input_ids, attention_mask, token_type_ids)
loss = criterion(outputs, labels) # 比較 預測結果 outputs(Sigmoid 的機率)和 真實答案 labels
# 用鏈式法則(Chain Rule)計算每一層「參數對 loss 的影響」,也就是梯度
# PyTorch 利用自動微分(autograd)幫你計算整個計算圖的偏導數,然後存在每一層的 .grad 裡。
loss.backward()
# 用 .grad 中的梯度資訊根
# 據學習率和優化器的規則
# 改變每一個參數的值,以讓下一次預測更接近真實
optimizer.step()
# loss 是一個 tensor(需要 backward);.item() 把它轉成 Python 的純數字(float)
total_loss += loss.item()
print(f"[Epoch{epoch+1}]Training Loss:{total_loss:.4f}")
torch.save(model.state_dict(), "model.pth")# 儲存模型權重
print("✅ 模型訓練完成並儲存為 model.pth")
"""
"""
整個模型中每一個文字(token)始終是一個向量,隨著層數不同,這個向量代表的意義會更高階、更語意、更抽象。
在整個 BERT + LSTM + CNN 模型的流程中,「每一個文字(token)」都會被表示成一個「向量」來進行後續的計算與學習。
今天我輸入一個句子:"早安你好,吃飯沒"
BERT 的輸入包含三個部分:input_ids、attention_mask、token_type_ids,
這些是 BERT 所需的格式。BERT 會將句子中每個 token 編碼為一個 768 維的語意向量,
進入 BERT → 每個 token 變成語意向量:
BERT 輸出每個字為一個 768 維的語意向量
「早」 → [0.23, -0.11, ..., 0.45] 長度為 768
「安」 → [0.05, 0.33, ..., -0.12] 一樣 768
...
batch size 是 8,句子長度是 8,輸出 shape 為:
[batch_size=8, seq_len=8, hidden_size=768]
接下來這些向量會輸入到 LSTM,LSTM不會改變「一個token是一個向量」的概念,而是重新表示每個token的語境向量。
把每個原本 768 維的 token 壓縮成 hidden_size=128,雙向 LSTM → 拼接 → 每個 token 成為 256 維向量:
input_size=768 是從 BERT 接收的向量維度
hidden_size=128 表示每個方向的 LSTM 會把 token 壓縮為 128 維語意向量
num_layers=1 表示只堆疊 1 層 LSTM
bidirectional=True 表示是雙向
LSTM,除了從左讀到右,也會從右讀到左,兩個方向的輸出會合併(拼接),變成:
[batch_size=8, seq_len=8, hidden_size=256] # 因為128*2
接下來進入 CNN,CNN 仍然以「一個向量代表一個字」的形式處理:
in_channels=256(因為 LSTM 是雙向輸出)
out_channels=128 表示學習出 128 個濾波器,每個濾波器專門抓一種 n-gram(例如「早安你」),每個「片段」的結果輸出為 128 維特徵
kernel_size=3 表示每個濾波器看 3 個連續 token(像是一個 3-gram)或,把相鄰的 3 個字(各為 256 維)一起掃描
padding=1 為了保留輸出序列長度和輸入相同,避免邊界資訊被捨棄
CNN 輸出的 shape 就會是:
[batch_size=8, out_channels=128, seq_len=8],還是每個 token 有對應一個向量(只是這向量是 CNN 抽出的新特徵)
""" |