From 47877a2159a9903e2e5237102c253bb4e698db5e Mon Sep 17 00:00:00 2001 From: 2509165029 <2509165029@student.edu.cn> Date: Thu, 30 Apr 2026 15:56:23 +0800 Subject: [PATCH] 4.30 --- 4.30.py | 700 ++++++++++++++++++++++++++++++++++++++++++++ 新建 XLS 工作表.xls | Bin 0 -> 19968 bytes 2 files changed, 700 insertions(+) create mode 100644 4.30.py create mode 100644 新建 XLS 工作表.xls diff --git a/4.30.py b/4.30.py new file mode 100644 index 0000000..bff9ba4 --- /dev/null +++ b/4.30.py @@ -0,0 +1,700 @@ +""" +数据加载与向量化模块 + +支持两种向量化方法: +1. BoW (Bag of Words) - 词频向量 +2. TF-IDF - 词频-逆文档频率向量 + +TF-IDF 的优势: +- 降低常见词(如"的"、"是")的权重 +- 提升罕见词的信息量 +- 通常效果优于简单BoW +""" + +import os +import re +import csv +import math +import jieba +import numpy as np +from collections import Counter + +try: + import urllib.request + import ssl + DOWNLOAD_AVAILABLE = True +except ImportError: + DOWNLOAD_AVAILABLE = False + + +DATASET_URL = "https://raw.githubusercontent.com/SophonPlus/ChineseNlpCorpus/master/datasets/ChnSentiCorp_htl_all/ChnSentiCorp_htl_all.csv" + + +def download_dataset(data_dir): + """下载数据集(如果不存在)""" + csv_path = os.path.join(data_dir, 'ChnSentiCorp_htl_all.csv') + + if os.path.exists(csv_path): + print(f"数据已存在: {csv_path}") + return True + + if not DOWNLOAD_AVAILABLE: + return False + + print("正在下载数据集...") + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + try: + request = urllib.request.Request(DATASET_URL, headers={'User-Agent': 'Mozilla/5.0'}) + response = urllib.request.urlopen(request, timeout=120, context=ssl_context) + os.makedirs(data_dir, exist_ok=True) + with open(csv_path, 'wb') as f: + f.write(response.read()) + print(f"下载完成: {csv_path}") + return True + except Exception as e: + print(f"下载失败: {e}") + return False + + +def load_raw_data(data_dir): + """加载原始数据""" + csv_path = os.path.join(data_dir, 'ChnSentiCorp_htl_all.csv') + texts, labels = [], [] + + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + for row in reader: + if len(row) < 2: + continue + try: + label = int(row[0]) + review = row[1].strip() + if review: + texts.append(review) + labels.append(label) + except (ValueError, IndexError): + continue + + return texts, np.array(labels) + + +def tokenize(text): + """中文分词""" + text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z]', ' ', text) + words = jieba.lcut(text) + return [w for w in words if len(w) > 1] + + +# ==================== 向量化器 ==================== + +class BaseVectorizer: + """向量化器基类""" + def fit(self, texts): pass + def transform(self, texts): pass + def fit_transform(self, texts): pass + + +class BoWVectorizer(BaseVectorizer): + """ + 词袋模型 (Bag of Words) + + 原理:统计每个词在文本中出现的次数 + 向量维度 = 词表大小 + 每个维度 = 该词在本文本中出现的次数 + """ + + def __init__(self, max_features, max_seq_len): + self.max_features = max_features + self.max_seq_len = max_seq_len + self.vocab = {} + self.doc_freq = {} # 文档频率 + self.num_docs = 0 + + def fit(self, texts): + """构建词表(基于词频)""" + counter = Counter() + doc_counter = Counter() # 统计包含该词的文档数 + + for text in texts: + words = tokenize(text) + unique_words = set(words) + counter.update(words) + for w in unique_words: + doc_counter[w] += 1 + + self.num_docs = len(texts) + + # 取最高频的词 + most_common = counter.most_common(self.max_features) + self.vocab = {word: idx for idx, (word, _) in enumerate(most_common)} + + # 记录文档频率(用于TF-IDF) + self.doc_freq = {w: doc_counter[w] for w in self.vocab} + + print(f" BoW词表大小: {len(self.vocab)}") + return self + + def transform(self, texts): + """将文本转换为词频向量""" + vectors = [] + for text in texts: + words = tokenize(text) + freq = [0] * self.max_seq_len + for i, word in enumerate(words[:self.max_seq_len]): + if word in self.vocab: + freq[i] = 1 # 二值(出现=1,不出现=0) + vectors.append(freq) + return np.array(vectors, dtype=np.float32) + + def fit_transform(self, texts): + self.fit(texts) + return self.transform(texts) + + +class TFIDFVectorizer(BaseVectorizer): + """ + TF-IDF 向量器 + + 原理: + - TF(词频) = 词在本文本中出现的次数 + - IDF(逆文档频率) = log(总文档数 / 包含该词的文档数) + - TF-IDF = TF × IDF + + 优势: + - 降低常见无意义词的权重(如"的"、"是") + - 提升罕见但有信息量的词 + """ + + def __init__(self, max_features, max_seq_len): + self.max_features = max_features + self.max_seq_len = max_seq_len + self.vocab = {} + self.idf = {} # 存储每个词的IDF值 + self.num_docs = 0 + + def fit(self, texts): + """构建词表并计算IDF""" + counter = Counter() + doc_counter = Counter() + + for text in texts: + words = tokenize(text) + unique_words = set(words) + counter.update(words) + for w in unique_words: + doc_counter[w] += 1 + + self.num_docs = len(texts) + + # 计算每个词的IDF + # IDF = log(总文档数 / 包含该词的文档数) + idf_values = {} + for word, df in doc_counter.items(): + idf_values[word] = math.log(self.num_docs / (df + 1)) + 1 # 加1防零 + + # 取IDF值最高的词(信息量最大的词) + sorted_words = sorted(idf_values.items(), key=lambda x: x[1], reverse=True) + self.vocab = {word: idx for idx, (word, _) in enumerate(sorted_words[:self.max_features])} + + # 保存IDF值 + self.idf = {word: idf_values[word] for word in self.vocab} + + print(f" TF-IDF词表大小: {len(self.vocab)}") + print(f" 平均IDF: {np.mean(list(self.idf.values())):.3f}") + return self + + def transform(self, texts): + """将文本转换为TF-IDF向量""" + vectors = [] + for text in texts: + words = tokenize(text) + + # 计算TF + tf = Counter(words) + tf_sum = len(words) if words else 1 + + # 生成向量 + vec = [0.0] * self.max_seq_len + for i, word in enumerate(words[:self.max_seq_len]): + if word in self.vocab: + # TF × IDF + vec[i] = (tf[word] / tf_sum) * self.idf.get(word, 0) + vectors.append(vec) + + return np.array(vectors, dtype=np.float32) + + def fit_transform(self, texts): + self.fit(texts) + return self.transform(texts) + + +def load_data(data_dir, max_features, max_seq_len, vectorizer_type='tfidf'): + """ + 加载并向量化数据 + + 参数: + - vectorizer_type: 'tfidf' 或 'bow' + """ + if not download_dataset(data_dir): + raise RuntimeError("数据加载失败,请检查网络或手动下载数据集") + + print("正在加载数据...") + texts, labels = load_raw_data(data_dir) + print(f"总评论数: {len(texts)}, 正面: {sum(labels)}, 负面: {len(labels) - sum(labels)}") + + # 选择向量化器 + if vectorizer_type == 'tfidf': + vectorizer = TFIDFVectorizer(max_features, max_seq_len) + vec_name = "TF-IDF" + else: + vectorizer = BoWVectorizer(max_features, max_seq_len) + vec_name = "BoW" + + print(f"正在使用{vec_name}向量化...") + X = vectorizer.fit_transform(texts) + y = labels + + # 打乱并划分 + np.random.seed(42) + indices = np.random.permutation(len(X)) + X = X[indices] + y = y[indices] + + split_idx = int(len(X) * 0.8) + X_train, X_test = X[:split_idx], X[split_idx:] + y_train, y_test = y[:split_idx], y[split_idx:] + + print(f"训练集: {len(X_train)}条, 测试集: {len(X_test)}条") + + return X_train, y_train, X_test, y_test, vectorizer + + +if __name__ == '__main__': + # 测试 + print("=" * 60) + print("测试 TF-IDF 向量化") + print("=" * 60) + X_train, y_train, X_test, y_test, vec = load_data( + 'data/ChnSentiCorp', max_features=3000, max_seq_len=100, + vectorizer_type='tfidf' + ) + print(f"\nX_train shape: {X_train.shape}") + print(f"X_train sample (前5个特征): {X_train[0][:5]}") +# -*- coding: utf-8 -*- +""" +配置文件 - 所有超参数集中管理 + +设计思路: +将超参数分门别类,学生可以单独修改某一类而不会影响其他 +""" + +# ==================== 数据相关 ==================== +DATA_DIR = 'data/ChnSentiCorp' # 数据集路径 +MAX_FEATURES = 3000 # 词表最大容量 +MAX_SEQ_LEN = 100 # 句子最大长度(词数) +VECTORIZER_TYPE = 'tfidf' # 'tfidf' 或 'bow'(向量化方式) + +# ==================== 模型相关 ==================== +MODEL_TYPE = 'mlp' # 'mlp' 或 'lr'(模型类型) +HIDDEN_SIZE = 64 # MLP隐藏层大小(LR忽略) +NUM_CLASSES = 2 # 类别数(正面/负面二分类) +KEEP_PROB = 1.0 # Dropout保留概率(LR忽略,设为1即可) + +# ==================== 训练相关 ==================== +LEARNING_RATE = 0.05 # 学习率 +NUM_EPOCHS = 100 # 训练轮数 +BATCH_SIZE = 64 # 批次大小 + +# ==================== 类别权重(解决数据不平衡问题)==================== +USE_CLASS_WEIGHT = True # True=启用类别权重, False=不启用(对比用) +# 权重计算公式: n_samples / (n_classes * n_class_i) +# 正面评论多所以权重小,负面评论少所以权重大 +CLASS_WEIGHT_POS = 0.73 # 正面类权重(自动计算) +CLASS_WEIGHT_NEG = 1.58 # 负面类权重(自动计算) + +# ==================== 实验相关 ==================== +RUN_COMPARISON = False # True=运行对比实验, False=运行单个模型 +COMPARE_MODELS = ['lr', 'mlp'] # 要对比的模型列表 +COMPARE_VECTORS = ['bow', 'tfidf'] # 要对比的向量化方式 + +# ==================== 其他 ==================== +RANDOM_SEED = 42 # 随机种子(保证可复现) +VERBOSE = True # 打印详细日志 +# -*- coding: utf-8 -*- +""" +主程序入口 + +使用方式: + +1. 运行单个模型(默认): + python main.py + + 修改 config.py 中的 MODEL_TYPE 和 VECTORIZER_TYPE 来切换配置 + +2. 运行对比实验: + 修改 config.py 中 RUN_COMPARISON = True + + 这会依次运行: + - 实验1: BoW vs TF-IDF (固定LR模型) + - 实验2: LR vs MLP (固定TF-IDF) + - 实验3: 不同学习率对比 + - 实验4: 不同隐藏层大小对比 + + 最后输出汇总报告 +""" + +from train import main + +if __name__ == '__main__': + print("\n" + "=" * 70) + print("文本分类实验 - 纯NumPy实现") + print("数据集: ChnSentiCorp (中文酒店评论)") + print("模型: Logistic Regression / MLP") + print("向量化: BoW / TF-IDF") + print("=" * 70 + "\n") + + main() +""" +模型模块 - 纯NumPy实现 + +支持两种模型: +1. Logistic Regression(逻辑回归)- 线性模型 +2. MLP(多层感知机)- 两层全连接网络 + +设计思路: +- 两种模型都共享相同的接口,方便对比 +- 代码简洁,每行都有详细注释 +- 手动实现反向传播,原理透明 +""" + +import numpy as np + + +class BaseModel: + """模型基类""" + def fit(self, X, y, X_val=None, y_val=None, epochs=100, batch_size=32, verbose=True): pass + def predict(self, X): pass + def predict_proba(self, X): pass + def accuracy(self, X, y): pass + + +class LogisticRegression(BaseModel): + """ + 逻辑回归(线性分类器) + + 结构:输入 → 线性变换 → Softmax → 输出 + + 原理: + - 线性变换: z = X @ W + b + - Softmax: 将线性输出转为概率分布 + + 参数量:input_size × num_classes + num_classes + """ + + def __init__(self, input_size, num_classes=2, learning_rate=0.1, + class_weight=None, seed=42): + np.random.seed(seed) + + # 权重初始化(Xavier) + self.W = np.random.randn(input_size, num_classes) * np.sqrt(2.0 / input_size) + self.b = np.zeros(num_classes) + + self.lr = learning_rate + self.input_size = input_size + self.num_classes = num_classes + self.class_weight = class_weight # 类别权重 + + total_params = input_size * num_classes + num_classes + print(f"LogisticRegression: {input_size} -> {num_classes}, 参数量: {total_params}") + + def softmax(self, x): + """Softmax函数""" + x_shifted = x - np.max(x, axis=1, keepdims=True) + exp_x = np.exp(x_shifted) + return exp_x / np.sum(exp_x, axis=1, keepdims=True) + + def forward(self, X): + """前向传播""" + # 线性变换 + z = X @ self.W + self.b + # Softmax输出概率 + return self.softmax(z) + + def backward(self, X, y): + """反向传播(梯度下降)""" + batch_size = X.shape[0] + probs = self.forward(X) + + # Softmax + 交叉熵梯度 + d_z = probs.copy() + + # 应用类别权重:减去权重值而不是1 + # 公式: dL/dz_y = w_y * (p_y - 1) = w_y*p_y - w_y + if self.class_weight is not None: + for i in range(batch_size): + d_z[i, y[i]] -= self.class_weight[y[i]] + else: + d_z[np.arange(batch_size), y] -= 1 + + # 梯度 + d_W = X.T @ d_z + d_b = np.sum(d_z, axis=0) + + # 更新 + self.W -= self.lr * d_W / batch_size + self.b -= self.lr * d_b / batch_size + + def fit(self, X, y, X_val=None, y_val=None, epochs=100, batch_size=32, verbose=True): + """训练""" + num_samples = len(X) + num_batches = (num_samples + batch_size - 1) // batch_size + + for epoch in range(epochs): + # 打乱 + indices = np.random.permutation(num_samples) + X_shuffled = X[indices] + y_shuffled = y[indices] + + epoch_loss = 0 + for batch_idx in range(num_batches): + start = batch_idx * batch_size + end = min(start + batch_size, num_samples) + X_batch = X_shuffled[start:end] + y_batch = y_shuffled[start:end] + + # 前向 + 反向 + probs = self.forward(X_batch) + self.backward(X_batch, y_batch) + + # 损失 + loss = -np.mean(np.log(np.clip(probs[np.arange(len(y_batch)), y_batch], 1e-10, 1))) + epoch_loss += loss + + # 评估 + if verbose and (epoch + 1) % 20 == 0: + train_acc = self.accuracy(X, y) + msg = f"Epoch {epoch+1:3d}/{epochs} | Loss: {epoch_loss/num_batches:.4f} | 训练准确率: {train_acc:.4f}" + if X_val is not None: + val_acc = self.accuracy(X_val, y_val) + msg += f" | 测试准确率: {val_acc:.4f}" + print(msg) + + return self + + def predict(self, X): + return np.argmax(self.forward(X), axis=1) + + def predict_proba(self, X): + return self.forward(X) + + def accuracy(self, X, y): + return np.mean(self.predict(X) == y) + + def save(self, filepath): + """保存模型权重""" + np.save(filepath + '_W.npy', self.W) + np.save(filepath + '_b.npy', self.b) + print(f"模型已保存: {filepath}") + + @staticmethod + def load(filepath, input_size, num_classes=2, learning_rate=0.1): + """加载模型权重""" + model = LogisticRegression(input_size, num_classes, learning_rate) + model.W = np.load(filepath + '_W.npy') + model.b = np.load(filepath + '_b.npy') + print(f"模型已加载: {filepath}") + return model + + +class MLP(BaseModel): + """ + 多层感知机(神经网络) + + 结构:输入 → 线性变换 → ReLU → 线性变换 → Softmax → 输出 + + 和LogisticRegression的区别: + - 多了一层隐藏层 + 非线性激活 + - 可以学习非线性关系 + - 参数量更大 + + 参数量: + - W1: input_size × hidden_size + - b1: hidden_size + - W2: hidden_size × num_classes + - b2: num_classes + """ + + def __init__(self, input_size, hidden_size=64, num_classes=2, + learning_rate=0.1, keep_prob=1.0, class_weight=None, seed=42): + np.random.seed(seed) + + # 第一层权重 + self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) + self.b1 = np.zeros(hidden_size) + + # 第二层权重 + self.W2 = np.random.randn(hidden_size, num_classes) * np.sqrt(2.0 / hidden_size) + self.b2 = np.zeros(num_classes) + + self.lr = learning_rate + self.keep_prob = keep_prob + self.hidden_size = hidden_size + self.input_size = input_size + self.num_classes = num_classes + self.class_weight = class_weight # 类别权重 + + total_params = (input_size * hidden_size + hidden_size + + hidden_size * num_classes + num_classes) + print(f"MLP: {input_size} -> {hidden_size} -> {num_classes}, 参数量: {total_params}") + + def relu(self, x): + """ReLU激活""" + return np.maximum(0, x) + + def relu_derivative(self, x): + """ReLU导数""" + return (x > 0).astype(float) + + def softmax(self, x): + """Softmax函数""" + x_shifted = x - np.max(x, axis=1, keepdims=True) + exp_x = np.exp(x_shifted) + return exp_x / np.sum(exp_x, axis=1, keepdims=True) + + def forward(self, X): + """前向传播""" + # 第一层 + self.z1 = X @ self.W1 + self.b1 + self.a1 = self.relu(self.z1) + + # Dropout(训练时) + if self.keep_prob < 1.0 and hasattr(self, 'training'): + self.d1 = (np.random.rand(*self.a1.shape) < self.keep_prob).astype(float) + self.a1 *= self.d1 + self.a1 /= self.keep_prob + + # 第二层 + self.z2 = self.a1 @ self.W2 + self.b2 + self.probs = self.softmax(self.z2) + + return self.probs + + def backward(self, X, y): + """反向传播""" + batch_size = X.shape[0] + + # 输出层梯度 + d_z2 = self.probs.copy() + + # 应用类别权重 + if self.class_weight is not None: + for i in range(batch_size): + d_z2[i, y[i]] -= self.class_weight[y[i]] + else: + d_z2[np.arange(batch_size), y] -= 1 + + # 第二层梯度 + d_W2 = self.a1.T @ d_z2 + d_b2 = np.sum(d_z2, axis=0) + + # 隐藏层梯度 + d_a1 = d_z2 @ self.W2.T + d_z1 = d_a1 * self.relu_derivative(self.z1) + + # Dropout梯度 + if self.keep_prob < 1.0 and hasattr(self, 'd1'): + d_z1 *= self.d1 + d_z1 /= self.keep_prob + + # 第一层梯度 + d_W1 = X.T @ d_z1 + d_b1 = np.sum(d_z1, axis=0) + + # 更新 + self.W1 -= self.lr * d_W1 / batch_size + self.b1 -= self.lr * d_b1 / batch_size + self.W2 -= self.lr * d_W2 / batch_size + self.b2 -= self.lr * d_b2 / batch_size + + def fit(self, X, y, X_val=None, y_val=None, epochs=100, batch_size=32, verbose=True): + """训练""" + num_samples = len(X) + num_batches = (num_samples + batch_size - 1) // batch_size + + for epoch in range(epochs): + # 打乱 + indices = np.random.permutation(num_samples) + X_shuffled = X[indices] + y_shuffled = y[indices] + + epoch_loss = 0 + self.training = True # 开启Dropout + + for batch_idx in range(num_batches): + start = batch_idx * batch_size + end = min(start + batch_size, num_samples) + X_batch = X_shuffled[start:end] + y_batch = y_shuffled[start:end] + + # 前向 + 反向 + probs = self.forward(X_batch) + self.backward(X_batch, y_batch) + + # 损失 + loss = -np.mean(np.log(np.clip(probs[np.arange(len(y_batch)), y_batch], 1e-10, 1))) + epoch_loss += loss + + self.training = False # 关闭Dropout + + # 评估 + if verbose and (epoch + 1) % 20 == 0: + train_acc = self.accuracy(X, y) + msg = f"Epoch {epoch+1:3d}/{epochs} | Loss: {epoch_loss/num_batches:.4f} | 训练准确率: {train_acc:.4f}" + if X_val is not None: + val_acc = self.accuracy(X_val, y_val) + msg += f" | 测试准确率: {val_acc:.4f}" + print(msg) + + return self + + def predict(self, X): + return np.argmax(self.forward(X), axis=1) + + def predict_proba(self, X): + return self.forward(X) + + def accuracy(self, X, y): + return np.mean(self.predict(X) == y) + + def save(self, filepath): + """保存模型权重""" + np.save(filepath + '_W1.npy', self.W1) + np.save(filepath + '_b1.npy', self.b1) + np.save(filepath + '_W2.npy', self.W2) + np.save(filepath + '_b2.npy', self.b2) + print(f"模型已保存: {filepath}") + + @staticmethod + def load(filepath, input_size, hidden_size=64, num_classes=2, learning_rate=0.1, keep_prob=1.0): + """加载模型权重""" + model = MLP(input_size, hidden_size, num_classes, learning_rate, keep_prob) + model.W1 = np.load(filepath + '_W1.npy') + model.b1 = np.load(filepath + '_b1.npy') + model.W2 = np.load(filepath + '_W2.npy') + model.b2 = np.load(filepath + '_b2.npy') + print(f"模型已加载: {filepath}") + return model + + +def create_model(model_type, input_size, hidden_size=64, num_classes=2, + learning_rate=0.1, keep_prob=1.0, class_weight=None): + """工厂函数:创建模型""" + if model_type == 'lr': + return LogisticRegression(input_size, num_classes, learning_rate, class_weight) + elif model_type == 'mlp': + return MLP(input_size, hidden_size, num_classes, learning_rate, keep_prob, class_weight) + else: + raise ValueError(f"未知模型类型: {model_type}") diff --git a/新建 XLS 工作表.xls b/新建 XLS 工作表.xls new file mode 100644 index 0000000000000000000000000000000000000000..1b4a4e195488e327abf987fe3f3661deb1657805 GIT binary patch literal 19968 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 literal 0 HcmV?d00001