Bennie12 commited on
Commit
aef356d
·
verified ·
1 Parent(s): 641c2ff

Upload 3 files

Browse files
Files changed (3) hide show
  1. AI_Model_architecture.py +494 -0
  2. app.py +166 -0
  3. bert_explainer.py +190 -0
AI_Model_architecture.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 流程圖
3
+ 讀取資料 → 分割資料 → 編碼 → 建立 Dataset / DataLoader
4
+
5
+ 建立模型(BERT+LSTM+CNN)
6
+
7
+ BERT 輸出 [batch, seq_len, 768]
8
+
9
+ BiLSTM [batch, seq_len, hidden_dim*2]
10
+
11
+ CNN 模組 (Conv1D + Dropout + GlobalMaxPooling1D)
12
+
13
+ Linear 分類器(輸出詐騙機率)
14
+
15
+ 訓練模型(Epochs)
16
+
17
+ 評估模型(Accuracy / F1 / Precision / Recall)
18
+
19
+ 儲存模型(.pth)
20
+
21
+ """
22
+ #引入重要套件Import Library
23
+
24
+ import os
25
+ import torch # PyTorch 主模組
26
+ import torch.nn as nn # 神經網路相關的層(例如 LSTM、Linear)
27
+ import pandas as pd
28
+ import re
29
+ import ast
30
+ import matplotlib.pyplot as plt
31
+ import seaborn as sns
32
+
33
+ from sklearn.metrics import (
34
+ classification_report, confusion_matrix, accuracy_score,
35
+ precision_score, recall_score, f1_score, roc_auc_score,
36
+ precision_recall_curve, auc, matthews_corrcoef
37
+ )
38
+ from dotenv import load_dotenv
39
+ from sklearn.model_selection import train_test_split
40
+ from torch.utils.data import DataLoader, Dataset # 提供 Dataset、DataLoader 類別
41
+ from transformers import BertTokenizer # BertTokenizer把文字句子轉換成 BERT 格式的 token ID,例如 [CLS] 今天 天氣 不錯 [SEP] → [101, 1234, 5678, ...]
42
+ from sklearn.model_selection import train_test_split
43
+
44
+ from transformers import BertModel
45
+ """
46
+ # ------------------- 載入 .env 環境變數 -------------------
47
+
48
+ path = r"E:\Project_PredictScamInfo"
49
+
50
+ # ------------------- 使用相對路徑找 CSV -------------------
51
+
52
+ #如有需要訓練複數筆資料可以使用這個方法csv_files = [os.path.join(base_dir, "檔案名稱1.csv"),os.path.join(base_dir, "檔案名稱2.csv")]
53
+ #程式碼一至131行
54
+
55
+ # GPU 記憶體限制(可選)
56
+ # os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "max_split_size_mb:16"
57
+
58
+ #資料前處理
59
+
60
+ class BertPreprocessor:
61
+ def __init__(self, tokenizer_name="ckiplab/bert-base-chinese", max_len=128):
62
+ self.tokenizer = BertTokenizer.from_pretrained(tokenizer_name)
63
+ self.max_len = max_len
64
+
65
+ def load_and_clean(self, filepath):
66
+ df = pd.read_csv(filepath)
67
+ df = df.dropna().drop_duplicates(subset=["message"]).reset_index(drop=True)
68
+
69
+ # 清理 message 欄位
70
+ df["message"] = df["message"].astype(str)
71
+ df["message"] = df["message"].apply(lambda text: re.sub(r"\s+", "", text))
72
+ df["message"] = df["message"].apply(lambda text: re.sub(r"[^\u4e00-\u9fffA-Za-z0-9。,!?]", "", text))
73
+
74
+ # 清理 keywords 欄位(如果有)
75
+ if "keywords" in df.columns:
76
+ df["keywords"] = df["keywords"].fillna("")
77
+ df["keywords"] = df["keywords"].apply(
78
+ lambda x: ast.literal_eval(x) if isinstance(x, str) and x.startswith("[") else []
79
+ )
80
+ df["keywords"] = df["keywords"].apply(
81
+ lambda lst: [re.sub(r"[^\u4e00-\u9fffA-Za-z0-9。,!?]", "", str(k)) for k in lst]
82
+ )
83
+ df["keywords"] = df["keywords"].apply(lambda lst: "。".join(lst))
84
+ else:
85
+ df["keywords"] = ""
86
+
87
+ # 合併為 BERT 輸入內容
88
+ df["combined"] = df["message"] + "。" + df["keywords"]
89
+ return df[["combined", "label"]]
90
+
91
+ def encode(self, texts):
92
+ return self.tokenizer(
93
+ list(texts),
94
+ return_tensors="pt",
95
+ truncation=True,
96
+ padding="max_length",
97
+ max_length=self.max_len
98
+ )
99
+ #自動做資料前處理
100
+ def build_bert_inputs(files):
101
+ processor = BertPreprocessor()
102
+ dfs = [processor.load_and_clean(f) for f in files]
103
+ all_df = pd.concat(dfs, ignore_index=True)
104
+ print("📌 原始資料筆數:", sum(len(pd.read_csv(f)) for f in files))
105
+ print("📌 清理後資料筆數:", len(all_df))
106
+ print(f"✅ 已讀入 {len(all_df)} 筆資料")
107
+ print(all_df["label"].value_counts())
108
+ print("📌 合併後輸入示例:")
109
+ print(all_df["combined"].head())
110
+
111
+ train_df, val_df = train_test_split(
112
+ all_df,
113
+ stratify=all_df["label"],
114
+ test_size=0.2,
115
+ random_state=25,
116
+ shuffle=True
117
+ )
118
+
119
+ train_inputs = processor.encode(train_df["combined"])
120
+ val_inputs = processor.encode(val_df["combined"])
121
+
122
+ return train_inputs, train_df["label"], val_inputs, val_df["label"], processor
123
+
124
+
125
+ #定義 PyTorch Dataset 類別。ScamDataset 繼承自 torch.utils.data.Dataset
126
+ #將 BERT 輸出的 token 與對應標籤封裝成 PyTorch 能使用的格式
127
+ class ScamDataset(Dataset):
128
+ def __init__(self, inputs, labels):
129
+ self.input_ids = inputs["input_ids"] # input_ids:句子的 token ID;attention_mask:注意力遮罩(0 = padding)
130
+ self.attention_mask = inputs["attention_mask"] # token_type_ids:句子的 segment 區分
131
+ self.token_type_ids = inputs["token_type_ids"] # torch.tensor(x, dtype=...)將資料(x)轉為Tensor的標準做法。
132
+ self.labels = torch.tensor(labels.values, dtype=torch.float32) # x可以是 list、NumPy array、pandas series...
133
+ # dtypefloat32:浮點數(常用於 回歸 或 BCELoss 二分類);long:整數(常用於 多分類 搭配 CrossEntropyLoss)。labels.values → 轉為 NumPy array
134
+
135
+ def __len__(self): # 告訴 PyTorch 這個 Dataset 有幾筆資料
136
+ return len(self.labels) # 給 len(dataset) 或 for i in range(len(dataset)) 用的
137
+
138
+ def __getitem__(self, idx): #每次調用 __getitem__() 回傳一筆 {input_ids, attention_mask, token_type_ids, labels}
139
+ return { #DataLoader 每次會呼叫這個方法多次來抓一個 batch 的資料
140
+ "input_ids":self.input_ids[idx],
141
+ "attention_mask":self.attention_mask[idx],
142
+ "token_type_ids":self.token_type_ids[idx],
143
+ "labels":self.labels[idx]
144
+ }
145
+
146
+ # 這樣可以同時處理 scam 和 normal 資料,不用重複寫清理與 token 處理
147
+ if __name__ == "__main__":
148
+ csv_files = [os.path.join(path, r"Filled_Keyword_MessageDeduplicated.csv")]
149
+ train_inputs, train_labels, val_inputs, val_labels, processor = build_bert_inputs(csv_files)
150
+
151
+ train_dataset = ScamDataset(train_inputs, train_labels)
152
+ val_dataset = ScamDataset(val_inputs, val_labels)
153
+
154
+ # batch_size每次送進模型的是 8 筆資料(而不是一筆一筆)
155
+ # 每次從 Dataset 中抓一批(batch)資料出來
156
+ train_loader = DataLoader(train_dataset, batch_size=128)
157
+ val_loader = DataLoader(val_dataset, batch_size=128)
158
+ """
159
+ """
160
+ class BertLSTM_CNN_Classifier(nn.Module)表示:你定義了一個子類別,
161
+ 繼承自 PyTorch 的基礎模型類別 nn.Module。
162
+
163
+ 若你在 __init__() 裡沒有呼叫 super().__init__(),
164
+ 那麼父類別 nn.Module 的初始化邏輯(包含重要功能)就不會被執行,
165
+ 導致整個模型運作異常或錯誤。
166
+ """
167
+
168
+ # nn.Module是PyTorch所有神經網路模型的基礎類別,nn.Module 是 PyTorch 所有神經網路模型的基礎類別
169
+ class BertLSTM_CNN_Classifier(nn.Module):
170
+
171
+ def __init__(self, hidden_dim=128, num_layers=1, dropout=0.3):
172
+
173
+ # super()是Python提供的一個方法,用來呼叫「父類別的版本」的方法。
174
+ # 呼叫:super().__init__()讓父類別(nn.Module)裡面那些功能、屬性都被正確初始化。
175
+ # 沒super().__init__(),這些都不會正確運作,模型會壞掉。
176
+ # super() 就是 Python 提供給「子類別呼叫父類別方法」的方式
177
+ super().__init__()
178
+
179
+ # 載入中文預訓練的 BERT 模型,輸入為句子token IDs,輸出為每個 token 的向量,大小為 [batch, seq_len, 768]。
180
+ self.bert = BertModel.from_pretrained("ckiplab/bert-base-chinese") # 這是引入hugging face中的tranceformat
181
+
182
+ # 接收BERT的輸出(768 維向量),進行雙向LSTM(BiLSTM)建模,輸出為 [batch, seq_len, hidden_dim*2],例如 [batch, seq_len, 256]
183
+ """
184
+ LSTM 接收每個token的768維向量(來自 BERT)作為輸入,
185
+ 透過每個方向的LSTM壓縮成128維的語意向量。
186
+ 由於是雙向LSTM,會同時從左到右(前向)和右到左(後向)各做一次,
187
+ 最後將兩個方向的輸出合併為256維向量(128×2)。
188
+ 每次處理一個 batch(例如 8 句話),一次走完整個時間序列。
189
+ """
190
+ self.LSTM = nn.LSTM(input_size=768,
191
+ hidden_size=hidden_dim,
192
+ num_layers=num_layers,
193
+ batch_first=True,
194
+ bidirectional=True)
195
+
196
+ # CNN 模組:接在 LSTM 後的輸出上。將LSTM的輸出轉成卷積層格式,適用於Conv1D,CNN可學習位置不變的局部特徵。
197
+ self.conv1 = nn.Conv1d(in_channels=hidden_dim*2,
198
+ out_channels=128,
199
+ kernel_size=3, # 這裡kernel_size=3 為 3-gram 特徵
200
+ padding=1)
201
+
202
+ self.dropout = nn.Dropout(dropout) # 隨機將部分神經元設為 0,用來防止 overfitting。
203
+
204
+ self.global_maxpool = nn.AdaptiveAvgPool1d(1) #將一整句話的特徵濃縮成一個固定大小的句子表示向量
205
+
206
+ # 將CNN輸出的128維特徵向量輸出為一個「機率值」(詐騙或非詐騙)。
207
+ self.classifier = nn.Linear(128,1)
208
+
209
+ def forward(self, input_ids, attention_mask, token_type_ids):
210
+ #BERT 編碼
211
+ outputs = self.bert(input_ids=input_ids,
212
+ attention_mask=attention_mask,
213
+ token_type_ids=token_type_ids)
214
+ #.last_hidden_state是BertModel.from_pretrained(...)內部的key,會輸出 [batch, seq_len, 768]
215
+ hidden_states = outputs.last_hidden_state
216
+
217
+ # 送入 BiLSTM
218
+ # transpose(1, 2) 的用途是:讓 LSTM 輸出的資料形狀符合 CNN 所要求的格式
219
+ # 假設你原本 LSTM 輸出是: [batch_size, seq_len, hidden_dim*2] = [8, 128, 256]
220
+ # 但CNN(Conv1d)的輸入格式需要是:[batch_size, in_channels, seq_len] = [8, 256, 128]
221
+ # 因此你需要做:.transpose(1, 2)把 seq_len 和 hidden_dim*2 調換
222
+ LSTM_out, _ = self.LSTM(hidden_states) # [batch, seq_len, hidden_dim*2]
223
+ LSTM_out = LSTM_out.transpose(1, 2) # [batch, hidden_dim*2, seq_len]
224
+
225
+ # 卷積 + Dropout
226
+ x = self.conv1(LSTM_out) # [batch, 128, seq_len]
227
+ x = self.dropout(x)
228
+
229
+ #全局池化
230
+ # .squeeze(dim) 的作用是:把某個「維度大小為 1」的維度刪掉
231
+ # x = self.global_maxpool(x).squeeze(2) # 輸出是 [batch, 128, 1]
232
+ # 不 .squeeze(2),你會得到 shape 為 [batch, 128, 1],不方便後面接 Linear。
233
+ # .squeeze(2)=拿掉第 2 維(數值是 1) → 讓形狀變成 [batch, 128]
234
+ x = self.global_maxpool(x).squeeze(2) # [batch, 128]
235
+
236
+ #分類 & Sigmoid 機率輸出
237
+ logits = self.classifier(x)
238
+
239
+ #.sigmoid() → 把 logits 轉成 0~1 的機率.squeeze() → 變成一維 [batch] 長度的機率 list
240
+ """例如:
241
+ logits = [[0.92], [0.05], [0.88], [0.41], ..., [0.17]]
242
+ → sigmoid → [[0.715], [0.512], ...]
243
+ → squeeze → [0.715, 0.512, ...]
244
+ """
245
+ return logits.squeeze() # 最後輸出是一個值介於 0 ~ 1 之間,代表「為詐騙訊息的機率」。
246
+ """
247
+ # 設定 GPU 裝置
248
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
249
+
250
+ # 初始化模型
251
+ model = BertLSTM_CNN_Classifier().to(device)
252
+ # 定義 optimizer 和損失函數
253
+ optimizer = torch.optim.Adam(model.parameters(),lr=2e-5)
254
+ pos_weight = torch.tensor([2.13], dtype=torch.float32).to(device)
255
+ criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
256
+
257
+ # 本機訓練迴圈,要訓練再取消註解,否則在線上版本一律處於註解狀態
258
+ # 訓練期間用的簡化版驗證函式 (只回傳 loss / acc)
259
+ def evaluate_epoch(model, dataloader, criterion, device):
260
+ model.eval()
261
+ total_loss = 0
262
+ all_labels, all_preds = [], []
263
+
264
+ with torch.no_grad():
265
+ for batch in dataloader:
266
+ input_ids = batch["input_ids"].to(device)
267
+ attention_mask = batch["attention_mask"].to(device)
268
+ token_type_ids = batch["token_type_ids"].to(device)
269
+ labels = batch["labels"].to(device)
270
+
271
+ outputs = model(input_ids, attention_mask, token_type_ids)
272
+ loss = criterion(outputs, labels)
273
+ total_loss += loss.item()
274
+
275
+ preds = (torch.sigmoid(outputs) > 0.5).long()
276
+ all_labels.extend(labels.cpu().numpy())
277
+ all_preds.extend(preds.cpu().numpy())
278
+
279
+ acc = accuracy_score(all_labels, all_preds)
280
+ avg_loss = total_loss / len(dataloader)
281
+ return avg_loss, acc
282
+
283
+ # 修改訓練主程式
284
+
285
+ def train_model(model, train_loader, val_loader, optimizer, criterion, device, num_epochs=15, save_path="model.pth"):
286
+ train_loss_list, val_loss_list = [], []
287
+ train_acc_list, val_acc_list = [], []
288
+
289
+ for epoch in range(num_epochs):
290
+ model.train()
291
+ total_train_loss = 0
292
+ train_true, train_pred = [], []
293
+
294
+ for batch in train_loader:
295
+ optimizer.zero_grad()
296
+ input_ids = batch["input_ids"].to(device)
297
+ attention_mask = batch["attention_mask"].to(device)
298
+ token_type_ids = batch["token_type_ids"].to(device)
299
+ labels = batch["labels"].to(device)
300
+
301
+ outputs = model(input_ids, attention_mask, token_type_ids)
302
+ loss = criterion(outputs, labels)
303
+ loss.backward()
304
+ optimizer.step()
305
+
306
+ total_train_loss += loss.item()
307
+ preds = (torch.sigmoid(outputs) > 0.5).long()
308
+ train_true.extend(labels.cpu().numpy())
309
+ train_pred.extend(preds.cpu().numpy())
310
+
311
+ train_acc = accuracy_score(train_true, train_pred)
312
+ train_loss = total_train_loss / len(train_loader)
313
+
314
+ val_loss, val_acc = evaluate_epoch(model, val_loader, criterion, device)
315
+
316
+ train_loss_list.append(train_loss)
317
+ val_loss_list.append(val_loss)
318
+ train_acc_list.append(train_acc)
319
+ val_acc_list.append(val_acc)
320
+
321
+ print(f"[Epoch {epoch+1}] Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | Train Acc: {train_acc:.4f} | Val Acc: {val_acc:.4f}")
322
+
323
+ torch.save(model.state_dict(), save_path)
324
+ print(f"✅ 模型訓練完成並儲存為 {save_path}")
325
+
326
+ # 可視化 Loss Curve
327
+ plt.figure(figsize=(8, 5))
328
+ plt.plot(range(1, num_epochs+1), train_loss_list, label="Train Loss")
329
+ plt.plot(range(1, num_epochs+1), val_loss_list, label="Val Loss")
330
+ plt.xlabel("Epoch")
331
+ plt.ylabel("Loss")
332
+ plt.title("Loss Curve")
333
+ plt.legend()
334
+ plt.show()
335
+
336
+ # 可視化 Accuracy Curve
337
+ plt.figure(figsize=(8, 5))
338
+ plt.plot(range(1, num_epochs+1), train_acc_list, label="Train Accuracy")
339
+ plt.plot(range(1, num_epochs+1), val_acc_list, label="Val Accuracy")
340
+ plt.xlabel("Epoch")
341
+ plt.ylabel("Accuracy")
342
+ plt.title("Accuracy Curve")
343
+ plt.legend()
344
+ plt.show()
345
+
346
+ # 訓練結束後繪製 Loss Curve
347
+ plt.figure(figsize=(8, 5))
348
+ plt.plot(range(1, num_epochs+1), train_loss_list, label="Train Loss")
349
+ plt.plot(range(1, num_epochs+1), val_loss_list, label="Val Loss")
350
+ plt.xlabel("Epoch")
351
+ plt.ylabel("Loss")
352
+ plt.title("Loss Curve")
353
+ plt.legend()
354
+ plt.show()
355
+
356
+ # 繪製 Accuracy Curve
357
+ plt.figure(figsize=(8, 5))
358
+ plt.plot(range(1, num_epochs+1), train_acc_list, label="Train Accuracy")
359
+ plt.plot(range(1, num_epochs+1), val_acc_list, label="Val Accuracy")
360
+ plt.xlabel("Epoch")
361
+ plt.ylabel("Accuracy")
362
+ plt.title("Accuracy Curve")
363
+ plt.legend()
364
+ plt.show()
365
+
366
+ def evaluate_model(model, val_loader, device):
367
+ model.eval()
368
+ y_true = []
369
+ y_pred = []
370
+ y_pred_prob = []
371
+
372
+ with torch.no_grad():
373
+ for batch in val_loader:
374
+ input_ids = batch["input_ids"].to(device)
375
+ attention_mask = batch["attention_mask"].to(device)
376
+ token_type_ids = batch["token_type_ids"].to(device)
377
+ labels = batch["labels"].to(device)
378
+
379
+ outputs = model(input_ids, attention_mask, token_type_ids)
380
+ probs = torch.sigmoid(outputs)
381
+ preds = (probs > 0.5).long()
382
+
383
+ y_true.extend(labels.cpu().numpy())
384
+ y_pred.extend(preds.cpu().numpy())
385
+ y_pred_prob.extend(probs.cpu().numpy())
386
+
387
+ acc = accuracy_score(y_true, y_pred)
388
+ prec = precision_score(y_true, y_pred)
389
+ rec = recall_score(y_true, y_pred)
390
+ f1 = f1_score(y_true, y_pred)
391
+ spec = recall_score(y_true, y_pred, pos_label=0)
392
+ mcc = matthews_corrcoef(y_true, y_pred)
393
+ roc_auc = roc_auc_score(y_true, y_pred_prob)
394
+ precision_curve, recall_curve, _ = precision_recall_curve(y_true, y_pred_prob)
395
+ pr_auc = auc(recall_curve, precision_curve)
396
+
397
+ metrics_dict = {
398
+ 'Accuracy': acc,
399
+ 'Precision': prec,
400
+ 'Recall': rec,
401
+ 'Specificity': spec,
402
+ 'F1-score': f1,
403
+ 'MCC': mcc,
404
+ 'ROC AUC': roc_auc,
405
+ 'PR AUC': pr_auc
406
+ }
407
+
408
+ # 視覺化:整體指標 bar chart
409
+ metric_names = list(metrics_dict.keys())
410
+ metric_values = list(metrics_dict.values())
411
+
412
+ plt.figure(figsize=(10, 6))
413
+ sns.barplot(y=metric_names, x=metric_values, palette="Blues_d")
414
+ for index, value in enumerate(metric_values):
415
+ plt.text(value + 0.01, index, f"{value:.4f}", va='center')
416
+ plt.title("模型評估指標")
417
+ plt.xlim(0, 1.05)
418
+ plt.xlabel("Score")
419
+ plt.ylabel("Metric")
420
+ plt.grid(axis='x', linestyle='--', alpha=0.7)
421
+ plt.tight_layout()
422
+ plt.show()
423
+
424
+ # 視覺化:Confusion Matrix
425
+ cm = confusion_matrix(y_true, y_pred)
426
+ plt.figure(figsize=(6, 5))
427
+ sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", xticklabels=["Scam (0)", "Normal (1)"], yticklabels=["Scam (0)", "Normal (1)"])
428
+ plt.xlabel("Predict")
429
+ plt.ylabel("Actual")
430
+ plt.title("Confusion Matrix")
431
+ plt.tight_layout()
432
+ plt.show()
433
+
434
+ # PR Curve (額外 bonus)
435
+ plt.plot(recall_curve, precision_curve, marker='.')
436
+ plt.title('Precision-Recall Curve')
437
+ plt.xlabel('Recall')
438
+ plt.ylabel('Precision')
439
+ plt.show()
440
+
441
+ # ROC Curve (額外 bonus)
442
+ from sklearn.metrics import RocCurveDisplay
443
+ RocCurveDisplay.from_predictions(y_true, y_pred_prob)
444
+ plt.title('ROC Curve')
445
+ plt.show()
446
+
447
+
448
+ # 放在主程式中呼叫
449
+ if __name__ == "__main__":
450
+ print("✅ 開始驗證模型效果...")
451
+ evaluate_model(model, val_loader, device)
452
+ """
453
+ """
454
+
455
+ 整個模型中每一個文字(token)始終是一個向量,隨著層數不同,這個向量代表的意義會更高階、更語意、更抽象。
456
+ 在整個 BERT + LSTM + CNN 模型的流程中,「每一個文字(token)」都會被表示成一個「向量」來進行後續的計算與學習。
457
+ 今天我輸入一個句子:"早安你好,吃飯沒"
458
+ BERT 的輸入包含三個部分:input_ids、attention_mask、token_type_ids,
459
+ 這些是 BERT 所需的格式。BERT 會將句子中每個 token 編碼為一個 768 維的語意向量,
460
+
461
+ 進入 BERT → 每個 token 變成語意向量:
462
+ BERT 輸出每個字為一個 768 維的語意向量
463
+ 「早」 → [0.23, -0.11, ..., 0.45] 長度為 768
464
+ 「安」 → [0.05, 0.33, ..., -0.12] 一樣 768
465
+ ...
466
+ batch size 是 8,句子長度是 8,輸出 shape 為:
467
+ [batch_size=8, seq_len=8, hidden_size=768]
468
+
469
+ 接下來這些向量會輸入到 LSTM,LSTM��會改變「一個token是一個向量」的概念,而是重新表示每個token的語境向量。
470
+ 把每個原本 768 維的 token 壓縮成 hidden_size=128,雙向 LSTM → 拼接 → 每個 token 成為 256 維向量:
471
+
472
+ input_size=768 是從 BERT 接收的向量維度
473
+ hidden_size=128 表示每個方向的 LSTM 會把 token 壓縮為 128 維語意向量
474
+ num_layers=1 表示只堆疊 1 層 LSTM
475
+ bidirectional=True 表示是雙向
476
+
477
+ LSTM,除了從左讀到右,也會從右讀到左,兩個方向的輸出會合併(拼接),變成:
478
+ [batch_size=8, seq_len=8, hidden_size=256] # 因為128*2
479
+
480
+ 接下來進入 CNN,CNN 仍然以「一個向量代表一個字」的形式處理:
481
+
482
+ in_channels=256(因為 LSTM 是雙向輸出)
483
+
484
+ out_channels=128 表示學習出 128 個濾波器,每個濾波器專門抓一種 n-gram(例如「早安你」),每個「片段」的結果輸出為 128 維特徵
485
+
486
+ kernel_size=3 表示每個濾波器看 3 個連續 token(像是一個 3-gram)或,把相鄰的 3 個字(各為 256 維)一起掃描
487
+
488
+ padding=1 為了保留輸出序列長度和輸入相同,避免邊界資訊被捨棄
489
+
490
+ CNN 輸出的 shape 就會是:
491
+
492
+ [batch_size=8, out_channels=128, seq_len=8],還是每個 token 有對應一個向量(只是這向量是 CNN 抽出的新特徵)
493
+
494
+ """
app.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """
3
+ -------一定要做步驟-------
4
+ 如果以anaconda開啟vscode請先確認有安狀下列套件
5
+ ctrl+shift+x找Live Server並安裝。Live Server是很好用的html前端工具。安裝後,html文件內,右鍵往下找Open with Live server
6
+ 在anaconda啟動頁面找anaconda_powershell_prompt下在下列套件,複製貼上就好
7
+
8
+ pip install fastapi uvicorn pydantic python-multipart aiofiles transformers huggingface_hub torch
9
+ pip install transformers huggingface_hub requests torch torchvision
10
+ pip install torch
11
+ pip install scikit-learn
12
+ pip install transformers torch
13
+ pip install --upgrade torch --extra-index-url https://download.pytorch.org/whl/cu118
14
+ pip install --upgrade torch --index-url https://download.pytorch.org/whl/cpu
15
+ pip install tqdm
16
+ pip install easyocr
17
+ pip install lime
18
+
19
+
20
+ ---測試本地前後端連接---
21
+ ->打開terminal再來按+號
22
+ ->點git bash
23
+ ->看到這表示正常,注意專案資料夾位置,像我的是D槽Project_PredictScamInfo
24
+ (user@LAPTOP-GPASQDRA MINGW64 /d/Project_PredictScamInfo (Update)$)
25
+ ->輸入 "cd Backend" (進入後端資料夾)
26
+ ->(/d/Project_PredictScamInfo/Backend)位址有Backend就是OK
27
+ ->輸入" uvicorn app:app --reload "
28
+ ->(INFO: Will watch for changes in these directories: ['D:\\Project_PredictScamInfo\\Backend']
29
+ INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit
30
+ INFO: Waiting for application startup.
31
+ INFO: Application startup complete.)
32
+ INFO: Started reloader process [70644] using StatReload)這樣表示正常
33
+ ->
34
+ ----正確顯示----
35
+ INFO: Uvicorn running on http://127.0.0.1:8000
36
+ INFO: Started reloader process...
37
+ """
38
+
39
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Form # 匯入 FastAPI 主功能模組與 HTTP 錯誤處理
40
+ from fastapi.middleware.cors import CORSMiddleware # 匯入 CORS 模組:用來允許前端跨來源存取 API
41
+ from pydantic import BaseModel # 用於定義 API 的資料結構模型
42
+ from datetime import datetime # 處理時間格式(如分析時間戳)
43
+ from typing import Optional, List # 型別註解:可選、列表
44
+ from bert_explainer import analyze_text, analyze_image # 匯入自定義的 BERT 模型分析函式
45
+
46
+ from fastapi.staticfiles import StaticFiles
47
+ from fastapi.responses import FileResponse
48
+
49
+ import os
50
+ import requests
51
+
52
+ # ---------------- 初始化 FastAPI 應用 ---------------
53
+ #團隊合作:前端工程師、測試人員知道你這API做什麼。會影響 /docs 文件清晰度與專案可讀性,在專案開發與交接時非常有用。
54
+ app = FastAPI(
55
+ title="詐騙訊息辨識 API", # 顯示「詐騙訊息辨識 API」
56
+ description="使用 BERT 模型分析輸入文字是否為詐騙內容",# 說明這個 API 的功能與用途
57
+ version="1.0.0" # 顯示版本,例如 v1.0.0
58
+ )
59
+
60
+ app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "..", "frontend")), name="static")
61
+
62
+ """
63
+ ---------------- 設定 CORS(允許跨網域請求) ----------------
64
+ FastAPI提供的內建方法,用來加入中介層(middleware)。在請求抵達API前,或回應送出前,先做某些處理的程式邏輯。
65
+ (Cross-Origin Resource Sharing)瀏覽器的安全機制。
66
+ """
67
+
68
+ app.add_middleware(
69
+ CORSMiddleware, # CORSMiddleware的功能就是「允許或拒絕」哪些來源能存取這個 API。
70
+ allow_origins=["*"], # 代表所有前端網域(如React前端、Vue前端)都可以發送請求。允許所有來源(不建議正式上線用 *)
71
+ allow_credentials=True,# 允許前端攜帶登入憑證或 Cookies 等認證資訊。如果你使用身份驗證、JWT Token、Session cookie,就要開啟這個。若你是公開 API,沒用到登入,那設成 False 也可以。
72
+ allow_methods=["*"], # 允許 GET, POST, PUT, DELETE, OPTIONS 等方法
73
+ allow_headers=["*"], # 允許自訂標頭(如Content-Type)對應JS第46段。如果沒在後端加上這行,附加在HTTP請求或回應中的「額外資訊」會被擋住。
74
+ )
75
+ # ---------------- 請求與回應資料模型 ----------------
76
+ #繼承自pydantic.BaseModel。FastAPI用來驗證與定義資料結構的標準方式,Pydantic提供自動的:
77
+ class TextAnalysisRequest(BaseModel):# 接收前端
78
+ text: str # 使用者輸入的訊息
79
+ user_id: Optional[str] = None # 可選的使用者 ID
80
+
81
+ class TextAnalysisResponse(BaseModel): # 回傳前端
82
+ status: str # 預測結果:詐騙/正常
83
+ confidence: float # 信心分數(通常為 100~0)
84
+ suspicious_keywords: List[str] # 可疑詞語清單(目前只會回傳風險分級顯示)
85
+ highlighted_text: str
86
+ analysis_timestamp: datetime # 分析完成時間(偏向資料庫用途,目前沒用到)
87
+
88
+
89
+ """
90
+ 這是 FastAPI 的路由裝飾器,代表:當使用者對「根目錄 /」發送 HTTP GET 請求時,要執行下面這個函數。
91
+ "/" 是網址的根路徑,例如開啟:"http://localhost:8000/"就會觸發這段程式。
92
+ 程式碼中/是API的根路徑。@app.get("/")代表使用者訪問網站最基本的路徑:http://localhost:8000/。這個/是URL路徑的根,不是資料夾。
93
+ """
94
+
95
+ @app.get("/", response_class=FileResponse)
96
+ async def read_index():
97
+ return FileResponse(os.path.join(os.path.dirname(__file__), "..", "frontend", "index.html"))
98
+ # 宣告一個非同步函數 root(),FastAPI 支援 async,
99
+ # 寫出高效能的非同步處理(像連資料庫、外部 API 等)
100
+ # 雖然這裡只是回傳資料,但仍建議保留 async
101
+ # Q:什麼是"非同步函數"(async def)?A:因為有些操作「會花時間」:等後端模型處理,等資料庫查詢,等外部 API 回應。用於處理"等待型操作"如資料庫、模型等。
102
+ # 還有保留 async 可以讓你未來擴充時不用重構。
103
+ # ---------------- 根目錄測試 API ----------------
104
+ @app.get("/")
105
+ async def root():
106
+ # 這是回傳給前端或使用者的一段 JSON 格式資料(其實就是 Python 的 dict)
107
+ return {
108
+ "message": "詐騙文字辨識 API 已啟動", # 說明這支 API 成功啟動
109
+ "version": "1.0.0", # 告訴使用者目前 API 的版本號
110
+ "status": "active", # 標示服務是否運行中(通常是 active 或 down)
111
+ "docs": "/docs" # 告訴使用者:自動生成的 API 文件在 /docs
112
+ # Q:/docs 是什麼?A:FastAPI 自動幫你建一個文件頁:看每個 API 的用途、參數格式
113
+ }
114
+ """
115
+ ---------------- 主要 /predict 預測端點 ----------------
116
+ 當前端呼叫這個 API,並傳入一段文字時,這段程式會依序做以下事情:
117
+ 程式碼內有特別註解掉資料庫部份,因為目前資料庫對該專案並不是特別重要,所以註解的方式,避免再Render佈署前後端網頁時出錯。
118
+ """
119
+ @app.post("/predict", response_model=TextAnalysisResponse)
120
+ async def analyze_text_api(request: TextAnalysisRequest):
121
+ try:
122
+ print("📥 收到請求:", request.text)
123
+ result = analyze_text(request.text)
124
+ print("✅ 模型回傳結果:", result)
125
+ return TextAnalysisResponse(
126
+ status=result["status"],
127
+ confidence=result["confidence"],
128
+ suspicious_keywords=result["suspicious_keywords"],
129
+ highlighted_text=result["highlighted_text"],
130
+ analysis_timestamp=datetime.now(),
131
+ )
132
+ except Exception as e:
133
+ print("❌ 錯誤訊息:", str(e))
134
+ raise HTTPException(status_code=500, detail="內部伺服器錯誤")
135
+
136
+
137
+ @app.post("/predict-image", response_model=TextAnalysisResponse)
138
+ async def predict_image_api(file: UploadFile = File(...)):
139
+ try:
140
+ print("📷 收到圖片上傳:", file.filename)
141
+ contents = await file.read()
142
+ result = analyze_image(contents)
143
+ return TextAnalysisResponse(
144
+ status=result["status"],
145
+ confidence=result["confidence"],
146
+ suspicious_keywords=result["suspicious_keywords"],
147
+ highlighted_text=result["highlighted_text"],
148
+ analysis_timestamp=datetime.now()
149
+ )
150
+ except Exception as e:
151
+ print("❌ 圖片處理錯誤:", str(e))
152
+ raise HTTPException(status_code=500, detail="圖片辨識或預測失敗")
153
+ """
154
+ 使用模型分析該文字(實際邏輯在 bert_explainer.py)
155
+ 呼叫模型進行詐騙分析,這會呼叫模型邏輯(在bert_explainer.py),把輸入文字送去分析,得到像這樣的回傳結果(假設):
156
+ result = {
157
+ "status": "詐騙",
158
+ "confidence": 0.93,
159
+ "suspicious_keywords": ["繳費", "網址", "限時"]
160
+ }
161
+
162
+ # 回傳結果給前端。對應script.js第60段註解。
163
+ # status、confidence、suspicious_keywords在script.js、app.py和bert_explainer是對應的變數,未來有需大更動,必須注意一致性。
164
+ """
165
+
166
+
bert_explainer.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
3
+ import jieba
4
+ import torch
5
+ import re
6
+ import easyocr
7
+ import io
8
+ import numpy as np
9
+ from PIL import Image
10
+ from huggingface_hub import hf_hub_download
11
+ from transformers import BertTokenizer
12
+ from AI_Model_architecture import BertLSTM_CNN_Classifier
13
+ from lime.lime_text import LimeTextExplainer
14
+
15
+ HF_TOKEN = os.environ.get("HF_TOKEN")
16
+
17
+
18
+ # OCR 模組
19
+ reader = easyocr.Reader(['ch_tra', 'en'], gpu=torch.cuda.is_available())
20
+
21
+ # 設定裝置(GPU 優先)
22
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
23
+
24
+ # 載入模型與 tokenizer
25
+ def load_model_and_tokenizer():
26
+ global model, tokenizer
27
+
28
+ if os.path.exists("model.pth"):
29
+ print("✅ 已找到 model.pth 載入模型")
30
+ model_path = "model.pth"
31
+ else:
32
+ print("🚀 未找到 model.pth")
33
+ model_path = hf_hub_download(repo_id="Bennie12/Bert-Lstm-Cnn-ScamDetecter",
34
+ filename="model.pth",
35
+ token=HF_TOKEN)
36
+
37
+ model = BertLSTM_CNN_Classifier()
38
+ model.load_state_dict(torch.load(model_path, map_location=device))
39
+ model.to(device)
40
+ model.eval()
41
+
42
+ tokenizer = BertTokenizer.from_pretrained("ckiplab/bert-base-chinese", use_fast=False)
43
+
44
+ return model, tokenizer
45
+
46
+ model, tokenizer = load_model_and_tokenizer()
47
+ model.eval()
48
+
49
+ # 預測單一句子的分類結果
50
+ def predict_single_sentence(model, tokenizer, sentence, max_len=256):
51
+ sentence = re.sub(r"\s+", "", sentence)
52
+ sentence = re.sub(r"[^\u4e00-\u9fffA-Za-z0-9。,!?:/._-]", "", sentence)
53
+
54
+ encoded = tokenizer(sentence, return_tensors="pt", truncation=True, padding="max_length", max_length=max_len)
55
+ encoded = {k: v.to(device) for k, v in encoded.items()}
56
+
57
+ with torch.no_grad():
58
+ output = model(encoded["input_ids"], encoded["attention_mask"], encoded["token_type_ids"])
59
+ prob = torch.sigmoid(output).item()
60
+ label = int(prob > 0.5)
61
+ risk = "🟢 低風險(正常)"
62
+ if prob > 0.9:
63
+ risk = "🔴 高風險(極可能是詐騙)"
64
+ elif prob > 0.5:
65
+ risk = "🟡 中風險(可疑)"
66
+
67
+ pre_label = '詐騙' if label == 1 else '正常'
68
+
69
+ return {
70
+ "label": pre_label,
71
+ "prob": prob,
72
+ "risk": risk
73
+ }
74
+
75
+ # 提供 LIME 用的 predict_proba
76
+ def predict_proba(texts):
77
+ # tokenizer 批次處理
78
+ encoded = tokenizer(
79
+ texts,
80
+ return_tensors="pt",
81
+ padding=True,
82
+ truncation=True,
83
+ max_length=256
84
+ )
85
+
86
+ # 移動到 GPU 或 CPU
87
+ encoded = {k: v.to(device) for k, v in encoded.items()}
88
+
89
+ with torch.no_grad():
90
+ outputs = model(encoded["input_ids"], encoded["attention_mask"], encoded["token_type_ids"])
91
+ # outputs shape: (batch_size,)
92
+ probs = torch.sigmoid(outputs).cpu().numpy()
93
+
94
+
95
+ # 轉成 LIME 格式:(N, 2)
96
+ probs_2d = np.vstack([1-probs, probs]).T
97
+ return probs_2d
98
+
99
+
100
+
101
+ # 初始化 LIME explainer
102
+ class_names = ['正常', '詐騙']
103
+ lime_explainer = LimeTextExplainer(class_names=class_names)
104
+
105
+ # 擷取可疑詞彙 (改用 LIME)
106
+
107
+ def suspicious_tokens(text, explainer=lime_explainer, top_k=5):
108
+ try:
109
+ explanation = explainer.explain_instance(text, predict_proba, num_features=top_k, num_samples=200)
110
+ keywords = [word for word, weight in explanation.as_list()]
111
+ return keywords
112
+ except Exception as e:
113
+ print("⚠ LIME 失敗,啟用 fallback:", e)
114
+ fallback = ["繳費", "終止", "逾期", "限時", "驗證碼"]
115
+ return [kw for kw in fallback if kw in text]
116
+
117
+
118
+ # 文字清理
119
+ def clean_text(text):
120
+ text = re.sub(r"https?://\S+", "", text)
121
+ text = re.sub(r"[a-zA-Z0-9:/.%\-_=+]{4,}", "", text)
122
+ text = re.sub(r"\+?\d[\d\s\-]{5,}", "", text)
123
+ text = re.sub(r"[^一-龥。,!?、]", "", text)
124
+ sentences = re.split(r"[。!?]", text)
125
+ cleaned = "。".join(sentences[:4])
126
+ return cleaned[:300]
127
+
128
+ # 高亮顯示
129
+ def highlight_keywords(text, keywords, prob):
130
+
131
+ if prob < 0.15: # 低風險完全不標註
132
+ return text
133
+
134
+ # 決定標註顏色
135
+ if prob >= 0.65:
136
+ css_class = 'red-highlight'
137
+ else:
138
+ css_class = 'yellow-highlight'
139
+ for word in keywords:
140
+ if len(word.strip()) >= 2:
141
+ text = text.replace(word, f"<span class='{css_class}'>{word}</span>")
142
+ return text
143
+
144
+
145
+
146
+
147
+ # 文字分析主流程
148
+ def analyze_text(text):
149
+ cleaned_text = clean_text(text)
150
+ result = predict_single_sentence(model, tokenizer, cleaned_text)
151
+ label = result["label"]
152
+ prob = result["prob"]
153
+ risk = result["risk"]
154
+
155
+ suspicious = suspicious_tokens(cleaned_text)
156
+ # 依照可疑度做不同標註
157
+ highlighted_text = highlight_keywords(text, suspicious, prob)
158
+ # 低風險下不回傳 suspicious_keywords
159
+ if prob < 0.15:
160
+ suspicious = []
161
+
162
+ print(f"\n📩 訊息內容:{text}")
163
+ print(f"✅ 預測結果:{label}")
164
+ print(f"📊 信心值:{round(prob*100, 2)}")
165
+ print(f"⚠️ 風險等級:{risk}")
166
+ print(f"可疑關鍵字擷取: {suspicious}")
167
+
168
+ return {
169
+ "status": label,
170
+ "confidence": round(prob * 100, 2),
171
+ "suspicious_keywords": suspicious,
172
+ "highlighted_text": highlighted_text
173
+ }
174
+
175
+ # 圖片 OCR 分析
176
+ def analyze_image(file_bytes):
177
+ image = Image.open(io.BytesIO(file_bytes))
178
+ image_np = np.array(image)
179
+ results = reader.readtext(image_np)
180
+
181
+ text = ' '.join([res[1] for res in results]).strip()
182
+
183
+ if not text:
184
+ return {
185
+ "status" : "無法辨識文字",
186
+ "confidence" : 0.0,
187
+ "suspicious_keywords" : ["圖片中無可辨識的中文英文"],
188
+ "highlighted_text": "無法辨識可疑內容"
189
+ }
190
+ return analyze_text(text)