简介这是一套面向高校计算机、人工智能与电子信息等专业学生的机器学习实践资源围绕恶意网站检测这一网络安全场景提供从特征提取到多算法验证的完整实现方案。压缩包共11个文件约3.32MB以Python脚本为主体辅以数据压缩包、备份文件与说明文档分别承担特征向量提取、可视化绘图、模型训练与项目说明等职责。系统先通过特征提取程序构建样本向量再借助可视化图表观察特征分布并以对照表作为标注依据随后分别用支持向量机、随机森林与深度神经网络三类算法完成分类验证交叉验证准确率均超过95%。代码模块结构清晰支持特征工程扩展与算法优化读者可据此掌握多模型对比实验的完整流程并在此基础上调整网络参数或引入增量学习机制。目前已有50人学习适合作为课程实践与算法入门的参考案例。1. 恶意网站检测这件事为什么传统规则库越来越不够用做安全运营的同行大概都有体会早上刚把一批新域名拉进黑名单下午攻击者就换了一批。基于 SVM、随机森林与 DNN 的恶意网站检测系统本质上就是想把这种「追着黑名单跑」的被动局面换成用模型从 URL 和页面特征里学出规律提前拦住没见过的恶意站点。它适合两类人一类是手里有日志、有样本想搭一套可迭代检测流水线的工程师另一类是想把机器学习真正落到安全场景、而不是停在调包跑数据集的学生和转行者。我见过太多团队一上来就纠结「用哪个模型最强」结果特征工程一塌糊涂SVM 和 DNN 的差距还不如换一版特征来得大。这篇笔记就按我实际搭过的一套流程来讲从数据怎么来、特征怎么抽到三个模型怎么并行跑、怎么对比、怎么上线最后把踩过的坑摊开说。你照着做能拿到一套可复现的基线系统而不是一个只在 notebook 里好看的准确率数字。2. 数据与特征检测系统的地基决定上限2.1 恶意网站样本从哪来怎么避免「喂错料」模型再花哨数据脏了全白搭。恶意网站检测的样本通常分两块恶意样本和正常样本。恶意样本常见来源是公开的恶意 URL 数据集、钓鱼站点举报数据、以及自己爬虫在蜜罐里捕获的落地页正常样本一般从常见站点排行榜里采样注意要覆盖不同语种和不同顶级域否则模型会学出「.cn 就是正常、.xyz 就是恶意」这种伪规律。我一般会把样本整理成三列url、label0 正常 / 1 恶意、source来源标记。source 这一列很关键后面做交叉验证时要按来源分层避免同一批爬虫抓的样本同时进训练和测试导致指标虚高。下面这段是把原始数据清洗成统一格式的脚本import pandas as pd from urllib.parse import urlparse def normalize_url(u: str) - str: u u.strip().lower() if not u.startswith((http://, https://)): u http:// u return u def load_and_clean(raw_path: str) - pd.DataFrame: df pd.read_csv(raw_path) # 统一列名兼容不同来源的字段命名 df df.rename(columns{URL: url, label: label}) df[url] df[url].astype(str).map(normalize_url) # 去掉解析不出 hostname 的脏数据 df[host] df[url].map(lambda x: urlparse(x).hostname) df df.dropna(subset[host]) # 去重同一 URL 只保留一条防止数据泄漏 df df.drop_duplicates(subset[url]).reset_index(dropTrue) return df[[url, host, label, source]]逻辑说明normalize_url 统一协议头和小写避免同一个站点因为写法不同被当成两条drop_duplicates 是防数据泄漏的第一道闸很多「准确率 99%」的翻车都源于训练集和测试集里有重复 URL。参数上raw_path 指向你的原始 CSV列名不一致时改 rename 里的映射即可。2.2 URL 词法特征不打开页面就能判断的第一层信号真正上线时你不可能对每个 URL 都去渲染页面所以词法特征是第一道防线。常用的有URL 长度、host 长度、路径深度、是否含 IP、特殊字符、-、数量、数字占比、是否含敏感词login、verify、account 等。这些特征计算快、可解释性强SVM 和随机森林在这层就能拿到不错的基线。import re from urllib.parse import urlparse SENSITIVE_WORDS [login, verify, account, secure, update, bank] def lexical_features(url: str) - dict: p urlparse(url) host, path p.hostname or , p.path or full url return { url_len: len(full), host_len: len(host), path_depth: path.count(/), has_ip: int(bool(re.match(r^\d{1,3}(\.\d{1,3}){3}$, host))), n_dots: full.count(.), n_hyphen: full.count(-), n_at: full.count(), n_digit: sum(c.isdigit() for c in full), digit_ratio: sum(c.isdigit() for c in full) / max(len(full), 1), has_sensitive: int(any(w in full.lower() for w in SENSITIVE_WORDS)), }逻辑说明has_ip 用正则判断 host 是不是纯 IP恶意站点常用 IP 直连规避域名封禁digit_ratio 用比例而非绝对数避免长 URL 天然数字多造成偏差。SENSITIVE_WORDS 是可调参数建议按你业务里高频被仿冒的品牌词来维护别照抄。2.3 页面内容特征把 HTML 变成模型能吃的向量词法特征不够时就得看页面内容。常见做法是提取页面标题、表单数量、外链域名数、是否含混淆脚本、iframe 数量等。这一步需要能抓取页面注意控制超时和并发别把目标站点打挂。下面用 requests BeautifulSoup 抽几个稳定特征import requests from bs4 import BeautifulSoup def page_features(url: str, timeout: int 5) - dict: try: r requests.get(url, timeouttimeout, headers{User-Agent: Mozilla/5.0}) html r.text except Exception: # 抓取失败时返回全 0让模型自己学「抓不到」这个信号 return {n_form: 0, n_iframe: 0, n_script: 0, n_ext_link: 0, title_len: 0} soup BeautifulSoup(html, html.parser) host urlparse(url).hostname or ext 0 for a in soup.find_all(a, hrefTrue): h urlparse(a[href]).hostname if h and h ! host: ext 1 return { n_form: len(soup.find_all(form)), n_iframe: len(soup.find_all(iframe)), n_script: len(soup.find_all(script)), n_ext_link: ext, title_len: len(soup.title.string) if soup.title and soup.title.string else 0, }逻辑说明抓取失败返回全 0 而不是抛异常是因为线上大量恶意站点会主动拒绝或超时这个「抓不到」本身就是强信号。timeout 设 5 秒是经验值再长会拖垮整条流水线并发建议用线程池控制在 20 以内具体看你的出口带宽和目标站点承受力。3. 三个模型怎么并行跑SVM、随机森林、DNN 的分工与实现3.1 特征拼接与数据集划分别让标准化污染测试集三个模型对输入的要求不一样SVM 对量纲敏感必须标准化随机森林对量纲不敏感可以直接吃原始特征DNN 也建议标准化加速收敛。所以流程上先把词法特征和页面特征拼成一张宽表再统一做划分标准化只在训练集上 fit然后 transform 测试集——这一步做错指标会虚高好几个点。import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def build_dataset(df): feats [] for u in df[url]: f lexical_features(u) f.update(page_features(u)) feats.append(f) X pd.DataFrame(feats).fillna(0).values y df[label].values # 按来源分层避免同源样本泄漏 X_tr, X_te, y_tr, y_te train_test_split( X, y, test_size0.2, random_state42, stratifydf[source] ) scaler StandardScaler().fit(X_tr) return scaler.transform(X_tr), scaler.transform(X_te), y_tr, y_te, scaler逻辑说明stratifydf[source] 保证每个来源在训练和测试里比例一致这是防泄漏的关键参数。scaler 只 fit 训练集测试集用同一个 scaler transform线上推理时也要保存这个 scaler 一起部署否则新数据分布对不上。3.2 SVM小样本高维场景下的稳定基线SVM 在特征维度高、样本量中等时表现稳尤其是 RBF 核。缺点是样本上万后训练慢调参靠网格搜索。我一般先用 LinearSVC 快速跑基线再用 SVC RBF 精调 C 和 gamma。from sklearn.svm import SVC from sklearn.metrics import classification_report def train_svm(X_tr, y_tr, X_te, y_te): # C 控制惩罚力度gamma 控制 RBF 核的影响半径 clf SVC(C1.0, gammascale, kernelrbf, class_weightbalanced, probabilityTrue) clf.fit(X_tr, y_tr) pred clf.predict(X_te) print(classification_report(y_te, pred, digits4)) return clf逻辑说明class_weightbalanced 在恶意样本远少于正常样本时很重要否则模型会偏向多数类。C 越大越容易过拟合gamma 越大决策边界越复杂建议从 C1、gammascale 起步再按验证集 F1 微调。probabilityTrue 会拖慢训练但线上要输出置信度就得开。3.3 随机森林抗噪、可解释、几乎不用调参随机森林是我最愿意先上线的模型对缺失值和异常值不敏感特征重要性直接可看调参空间小。n_estimators 一般 200 到 500 够用max_depth 不设或设大一点让它自己长。from sklearn.ensemble import RandomForestClassifier def train_rf(X_tr, y_tr, X_te, y_te, feature_names): clf RandomForestClassifier( n_estimators300, max_depthNone, min_samples_leaf2, class_weightbalanced_subsample, n_jobs-1, random_state42 ) clf.fit(X_tr, y_tr) pred clf.predict(X_te) print(classification_report(y_te, pred, digits4)) # 输出特征重要性方便排查伪特征 imp sorted(zip(feature_names, clf.feature_importances_), keylambda x: -x[1]) for name, v in imp[:10]: print(f{name}: {v:.4f}) return clf逻辑说明min_samples_leaf2 能抑制噪声样本导致的过拟合class_weightbalanced_subsample 是按每棵树的 bootstrap 样本单独算权重比 balanced 更适合不平衡数据。特征重要性排前几的如果出现明显伪特征比如某个来源标记混进来了要回头查数据。3.4 DNN用 Keras 搭一个够用就好的分类网络DNN 的优势是能自动学特征交互但恶意网站检测这种表格特征场景网络不用太深两三层全连接足够。层数堆多了小数据集上必过拟合。import tensorflow as tf from tensorflow.keras import layers, models def build_dnn(input_dim: int) - models.Model: model models.Sequential([ layers.Input(shape(input_dim,)), layers.Dense(128, activationrelu), layers.Dropout(0.3), layers.Dense(64, activationrelu), layers.Dropout(0.3), layers.Dense(1, activationsigmoid), ]) model.compile( optimizertf.keras.optimizers.Adam(1e-3), lossbinary_crossentropy, metrics[accuracy, tf.keras.metrics.AUC(nameauc)], ) return model def train_dnn(X_tr, y_tr, X_te, y_te): model build_dnn(X_tr.shape[1]) es tf.keras.callbacks.EarlyStopping(patience3, restore_best_weightsTrue) model.fit(X_tr, y_tr, validation_split0.1, epochs30, batch_size64, callbacks[es], verbose2) print(model.evaluate(X_te, y_te)) return model逻辑说明Dropout 0.3 是表格数据的常用值再高会欠拟合EarlyStopping patience3 防止在验证集上反复横跳。batch_size 64 在几千到几万样本时比较稳样本量小就降到 32。注意 DNN 对标准化最敏感前面 scaler 那步不能省。4. 模型对比与上线指标之外还要看什么4.1 别只看准确率恶意检测要看召回和误报成本恶意网站检测里漏报把恶意判成正常和误报把正常判成恶意的代价完全不同。漏报可能意味着一次入侵误报可能意味着用户打不开正常网站。所以评估时我优先看恶意类的召回率再看误报率最后才看整体准确率。下面这张表是我在一份几万条样本上的典型对比具体数字随数据变化看趋势即可模型恶意类召回误报率训练耗时可解释性SVM(RBF)较高中等中低随机森林高低快高DNN高中慢低结论通常是随机森林做线上主力SVM 做兜底校验DNN 用来挖掘特征交互、给特征工程提供方向。别指望一个模型打天下。4.2 阈值调优把概率输出变成可运营的决策模型输出的是概率上线要定阈值。阈值不是拍脑袋而是按业务能接受的误报率反推。下面这段是画 P-R 曲线找阈值的做法from sklearn.metrics import precision_recall_curve def pick_threshold(y_true, y_prob, max_fpr0.01): prec, rec, thr precision_recall_curve(y_true, y_prob) # 找到误报率不超过 max_fpr 时召回最高的阈值 best_t, best_r 0.5, 0 for p, r, t in zip(prec, rec, thr): if (1 - p) max_fpr and r best_r: best_r, best_t r, t return best_t, best_r逻辑说明max_fpr 是你业务能容忍的误报上限比如 1%。这个函数在满足误报约束的前提下最大化召回。线上建议把阈值做成配置项随流量和攻击态势动态调而不是写死在代码里。4.3 模型持久化与推理服务别让训练和上线两套代码训练完的 scaler、模型、特征顺序必须一起保存否则线上推理时特征对不上结果全是玄学。我一般用 joblib 存 sklearn 模型DNN 存 SavedModel再写一个统一的 predict 入口。import joblib def save_artifacts(scaler, rf, svm, feature_names, pathartifacts): import os os.makedirs(path, exist_okTrue) joblib.dump(scaler, f{path}/scaler.pkl) joblib.dump(rf, f{path}/rf.pkl) joblib.dump(svm, f{path}/svm.pkl) joblib.dump(feature_names, f{path}/features.pkl) def predict_one(url, scaler, model, feature_names): f lexical_features(url) f.update(page_features(url)) x np.array([[f.get(k, 0) for k in feature_names]]) x scaler.transform(x) return model.predict_proba(x)[0, 1]逻辑说明feature_names 单独存是为了保证推理时特征顺序和训练完全一致这是血泪经验——顺序错一位模型表现直接崩。predict_one 里用 f.get(k, 0) 兜底防止线上某个特征抽取失败导致整条报错。5. 避坑与排查那些让指标虚高、上线翻车的细节5.1 数据泄漏准确率 99% 的头号元凶现象离线评估准确率 99%上线后召回惨不忍睹。原因训练集和测试集里有重复 URL或者同一批爬虫抓的样本被分到两边模型其实在背答案。解决按 URL 去重按来源分层划分最好再按时间划分——用旧数据训练、新数据测试更接近真实场景。5.2 类别不平衡模型学会「全判正常」现象恶意类召回极低但整体准确率很高。原因恶意样本占比可能只有几个百分点模型直接全判正常就能拿高准确率。解决class_weight 设 balanced或用欠采样/过采样评估时盯住恶意类召回和 F1别被整体准确率骗了。5.3 特征穿越用了未来才有的信息现象某个特征重要性异常高但上线后该特征经常取不到。原因比如用了「该域名是否在最新黑名单里」这种特征训练时黑名单已经包含答案线上却拿不到。解决逐个审查高重要性特征凡是依赖标注结果或未来信息的一律剔除。5.4 抓取特征不稳定线上大量超时拖垮服务现象推理延迟忽高忽低高峰期大面积超时。原因page_features 同步抓取页面目标站点慢或拒绝连接时阻塞整条流水线。解决给抓取设硬超时失败返回默认值把页面特征做成异步预取或缓存别在推理主路径上同步抓。5.5 阈值写死攻击态势变了模型没跟上现象上线初期效果不错几周后误报飙升。原因攻击者换了手法特征分布漂移固定阈值不再适用。解决定期用新样本重训监控线上概率分布阈值做成可配置配合人工复核样本回流形成闭环。6. 让这套系统真正跑起来的两个进阶技巧第一个技巧是把三个模型的输出做 stacking而不是简单投票。具体做法用 SVM、随机森林、DNN 在验证集上的概率输出作为新特征再训一个逻辑回归做融合。这样能利用不同模型在不同样本上的互补性通常比单模型召回高几个点。注意融合层的训练数据必须和基模型训练数据分开否则一样会过拟合。from sklearn.linear_model import LogisticRegression def stack_fusion(svm_prob, rf_prob, dnn_prob, y_val): X_meta np.column_stack([svm_prob, rf_prob, dnn_prob]) meta LogisticRegression(class_weightbalanced).fit(X_meta, y_val) return meta逻辑说明三个概率作为元特征逻辑回归学出各自权重。class_weight 同样要设保持对恶意类的敏感。上线时三个基模型并行推理再走一次 meta 预测延迟增加有限。第二个技巧是建立样本回流闭环。线上每次人工复核的判定结果都带着原始 URL 和特征存回样本库每周增量重训一次。这一步听起来朴素但它是这套系统能不能长期活下去的关键。我自己的习惯是任何一次误报先别急着改模型先把那条样本捞出来看特征十有八九是特征抽取环节出了问题而不是模型本身不行。模型是黑匣子特征才是你能掌控的部分。把特征管好SVM、随机森林、DNN 谁强谁弱反而没那么重要了。希望帮到你。本文还有配套的精品资源点击获取