前言
本项目基于LSTM (Long Short-Term Memory)也就是常说的长短期记忆神经网络 通过对4万条在IMDb网站上的电影评论进行训练 使其构建成一个能够自动分析英文影评情感的系统
本项目的主要目标:
1. 从数据处理开始 构建并训练一个高效的LSTM模型 使其能够分析该条电影评论的情感倾向。
2. 开发一个基于tkinter的简单GUI界面 如上图所示 可直观的显示输入的评论并获得情感分析的结果
每段代码都附有注释 便于简单的理解代码的逻辑和流程 但并未对底层逻辑进行详细解释 所以如果不知道LSTM相关知识的话 可以先去b站简单补习一下
文件结构
IMBd analysis/
│
├── data/
│ └── IMDB Dataset.csv
│
├── model/
│ ├── sentiment_model.pt
│ └── training.log
│
├── preprocessed_data/
│ ├── x.pkl
│ ├── y.pkl
│ ├── index_to_word.pkl
│ ├── word_to_index.pkl
│ ├── word_count.pkl
│ ├── corpus_idx.pkl
│ ├── X_test.pkl
│ ├── X_train.pkl
│ ├── y_test.pkl
│ ├── y_train.pkl
│ └── y_converted.pkl
│
├── utils/
│ ├── __init__.py
│ ├── data_loader.ipynb
│ ├── data_loader.py
│ ├── vocab_builder.ipynb
│ ├── vocab_builder.py
│ ├── data_process.py
│ ├── model.py
│ ├── train.py
│ ├── predict.py
│ ├── predict_review.py
│ └── GUI.py
└── main.py
包初始化 __init__.py
import nltk
nltk.download('wordnet')
# 下载wordnet数据包 用于词形还原
nltk.download('stopwords')
# 下载停用词数据包 过滤没用的词汇
# 注意 如果下载完之后运行还是提示无法找到这两个数据包的话
# 可以看一下C盘-用户-你的名字-然后打开隐藏文件AppData
# 然后找到Roaming-nltk_data-corpora 找到这两个数据包
# 的压缩zip形式 然后解压到corpora里 就可以了
数据加载 data_loader.py
数据集下载地址:点击跳转
该数据集包含了将近五万条的英文电影评论 所以运行时间可能会有点长 调试的时候建议使用jupyter notebook或者只取1000条
import pickle
import re
import pandas as pd
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
from nltk.stem import WordNetLemmatizer
def clean_text(text):
text = re.sub(r'<.*?>', '', text)
# 清除内涵的html标签
text = re.sub(r'[^a-zA-Z]',' ',text)
# 清楚所有非字母元素
text = text.lower()
# 全部转化为小写
words = word_tokenize(text)
# 对这一段文本进行分词拆分
lemmatizer = WordNetLemmatizer()
# 初始化词形还原器
words = [lemmatizer.lemmatize(word) for word in words if word not in stopwords.words('english')]
# 将所有词形进行还原 并同时判断其是否为停用词 不是的话将其储存
# 停用词为不包含什么实际信息并且出现频率较多的词汇 例如the is a
text = ' '.join(words)
# 将处理过的单词进行重新组合
return text
def dataloader():
filename = './data/IMDB Dataset.csv'
data = pd.read_csv(filename)
# 用pandas取出数据 形成DataFrame形式 便与后续的处理
x = data['review']
y = data['sentiment']
# 取出评论和情感标签
for i in range(len(x)):
x[i] = clean_text(x[i])
if i % 1000 == 0:
print(i)
# 便于观测数据处理进度
with open('preprocessed_data/x.pkl', 'wb') as f:
pickle.dump(x, f)
with open('preprocessed_data/y.pkl', 'wb') as f:
pickle.dump(y, f)
# 将处理后的数据以二进制形式序列化保存到文件中 便于以后的取用
词汇构建 vocab_builder.py
import pickle
from nltk.tokenize import word_tokenize
from tensorflow import keras
from sklearn.model_selection import train_test_split
def vocab_builder():
with open('../preprocessed_data/x.pkl', 'rb') as f:
x = pickle.load(f)
with open('../preprocessed_data/y.pkl', 'rb') as f:
y = pickle.load(f)
index_to_word, all_words = ['PAD',],[]
# 初始化词汇表和单词表 因为后续要将不满100个单词的句子用0填充
# 所以词汇表的第一位‘0’ 为填充‘PAD’
i = 0
for line in x:
i+=1
if i % 1000 == 0:
print(i)
# 看看遍历到哪了
words = word_tokenize(line)
# 对每一行文本进行分词
all_words.append(words)
# 将每一份的文本分词添加到单词表中
for word in words:
if word not in index_to_word:
index_to_word.append(word)
# 构建唯一单词的词汇表 用于将索引转换为单词
word_to_index = {word:idx for idx,word in enumerate(index_to_word)}
# 反过来构建一下词汇对索引的表示 方便将词汇转换为索引
word_count = len(index_to_word)
corpus_idx = []
# 用于存放每句话转换完索引之后的数据
for line in all_words:
temp = []
for word in line:
temp.append(word_to_index[word])
# 这是完整的一段文本 也就是一条评论的索引数据
temp = keras.preprocessing.sequence.pad_sequences([temp], maxlen=100, padding='post')
# maxlen=100 将超过100个词汇的评论裁剪为100
# padding='post' 不够100条的在词尾填充‘0’
corpus_idx.append(temp[0])
# 因为pad_sequences返回的是二维数组 所以这里要用temp[0]来提取内容
# 否则corpus_idx 将变成一个三维数组
label_to_index = {"positive": 1, "negative": 0}
y_converted = [label_to_index[label] for label in y]
# 构建一个字典 将目标值转换为1或0
X_train, X_test, y_train, y_test = train_test_split(corpus_idx,y_converted, test_size=0.2)
# 划分训练集和测试集 8:2
with open('../preprocessed_data/index_to_word.pkl', 'wb') as f:
pickle.dump(index_to_word, f)
with open('../preprocessed_data/word_to_index.pkl', 'wb') as f:
pickle.dump(word_to_index, f)
with open('../preprocessed_data/word_count.pkl', 'wb') as f:
pickle.dump(word_count, f)
with open('../preprocessed_data/corpus_idx.pkl', 'wb') as f:
pickle.dump(corpus_idx, f)
with open('../preprocessed_data/y_converted.pkl', 'wb') as f:
pickle.dump(y_converted,f)
with open('../preprocessed_data/X_train.pkl', 'wb') as f:
pickle.dump(X_train,f)
with open('../preprocessed_data/X_test.pkl', 'wb') as f:
pickle.dump(X_test,f)
with open('../preprocessed_data/y_train.pkl', 'wb') as f:
pickle.dump(y_train,f)
with open('../preprocessed_data/y_test.pkl', 'wb') as f:
pickle.dump(y_test,f)
# 保存数据
数据处理 data_process.py
import torch
from torch.utils.data import Dataset
class data_process(Dataset):
def __init__(self,X,y):
self.X = X
self.y = y
# 初始化 用于接收数据和目标值
def __len__(self):
return len(self.X)
# 返回数据的长度 方便dataloader根据批量大小进行处理
def __getitem__(self, idx):
return torch.tensor(self.X[idx], dtype=torch.long), torch.tensor(self.y[idx], dtype=torch.float32)
# 将数据转化为张量并返回
模型构建 model.py
import torch
import torch.nn as nn
import torch.nn.functional as F
class sentimentmodel(nn.Module):
def __init__(self,word_count,output):
super(sentimentmodel, self).__init__()
self.ebd = nn.Embedding(word_count,256)
# 定义词嵌入层 将词汇数量映射为256维的向量
self.lstm = nn.LSTM(256,256,1,batch_first=True)
# 定义lstm层 进行一次循环 并且将batch_size置为第一维(好习惯)
self.out = nn.Linear(256,output)
#定义全连接层
def forward(self,X,hidden):
ebd = self.ebd(X)
# 首先通过嵌入曾 将词汇索引转换为词向量
ebd = F.dropout(ebd, p=0.5,training=self.training)
# 使用dropout使每一次训练时神经元失效一部分 防止过拟合
# 并且只在训练中生效
ebd = ebd.squeeze(1)
# 去除维度为1的维度 简化张量形状并使得其贴合方法的处理
ebd, hidden = self.lstm(ebd,hidden)
# 进入LSTM层进行运算
ebd = ebd[:,-1,:]
# 取出最后一个时间步的输出
out = self.out(ebd)
# 将最后一个时间步通过全连接层得到输出
return torch.sigmoid(out),hidden
# 将输出的值映射到0-1之间 方便进行0 1判断
def init_hidden(self, batch_size):
return (torch.zeros(1, batch_size, 256).to(next(self.parameters()).device)
,torch.zeros(1, batch_size, 256).to(next(self.parameters()).device))
# 初始化并返回隐藏层和细胞层的全0状态 并且使其在同一GPU或CPU上进行计算
训练脚本 train.py
import pickle
import time
import torch
from torch import nn, optim
from torch.utils.data import DataLoader
from utils.data_process import data_process
from utils.model import sentimentmodel
def train():
with open('D:/IMBd analysis/preprocessed_data/X_train.pkl', 'rb') as f:
X_train = pickle.load(f)
with open('D:/IMBd analysis/preprocessed_data/y_train.pkl', 'rb') as f:
y_train = pickle.load(f)
with open('D:/IMBd analysis/preprocessed_data/word_count.pkl', 'rb') as f:
word_count = pickle.load(f)
train_process = data_process(X_train, y_train)
# 创建数据处理的对象
model = sentimentmodel(word_count, 1)
# 初始化模型 输出设定为一
dataloader = DataLoader(train_process, batch_size=32, shuffle=True,drop_last=True)
# 创建数据加载器 每32个为一组 并且最后不足32个的舍弃
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.to(device)
# 如果GPU可用的话就在GPU上运行 否则CPU
criterion = nn.BCELoss()
# 使用二分类交叉熵损失函数 更好的度量与测概率和目标值之间的误差
optimizer = optim.Adam(model.parameters(), lr=1e-4)
# 使用Adam自适应学习率来优化参数更新
epoch = 35
# 设置训练轮数
train_log = './model/training.log'
file = open(train_log, 'w')
# 保存训练时的日志(好习惯)
for epoch in range(epoch):
epoch_idx = 0
total_loss = 0.0
start_time = time.time()
# epoch_idx用于记录处理了多少个batch 方便求平均损失
# total_loss用于计算每一次训练的总损失
# 记录时间 计算每一次训练的时长
for X, y in dataloader:
X, y = X.to(device), y.to(device)
# 将数据和目标值移动到可用设备上
hidden = model.init_hidden(batch_size=X.size(0))
# 初始化隐藏状态
output, hidden = model(X,hidden)
# 向前传播 得到更新后的输出值和隐藏状态
output = output.squeeze()
# 去除多余的维度
optimizer.zero_grad()
# 梯度清零 防止梯度累加
loss = criterion(output, y)
# 计算损失
total_loss += loss.item()
# 将损失累加 注意要加item取出标量值 否则就是一个张量
loss.backward()
# 反向传播
optimizer.step()
# 更新模型参数
epoch_idx += 1
message = 'Epoch:{}, Loss:{:.4f}, Time{:.2f}'.format(epoch+1, total_loss / epoch_idx, time.time() - start_time)
file.write(message + '\n')
print(message)
# 将每一轮训练的信息打印并输出 方便发现训练时的问题
file.close()
torch.save(model.state_dict(), '../model/sentiment_model.pt')
# 将日志文件关闭 并且保存训练好的模型
预测脚本1 predict.py
该预测脚本预测的是通过上面划分的测试集 进行准确率的预测
import pickle
import torch
from torch.utils.data import DataLoader
from utils.data_process import data_process
from utils.model import sentimentmodel
def predict():
word_count, X_test, y_test = load()
test_process = data_process(X_test, y_test)
test_loader = DataLoader(test_process, batch_size=32, shuffle=False)
model = sentimentmodel(word_count, 1)
model.load_state_dict(torch.load('./model/sentiment_model.pt', map_location=torch.device('cpu')))
# 加载训练好的模型
model.eval()
# 将模型设置为评估模式
total_correct = 0
total_count = 0
with torch.no_grad():
# 禁用梯度计算 在测试的时候没有必要使用后梯度
for X, y in test_loader:
hidden = model.init_hidden(batch_size=X.size(0))
# 初始化隐藏状态
outputs, _ = model(X, hidden)
# 向前传播 且不需要使用隐藏状态 因为不需要向后传播
outputs = outputs.squeeze()
# 去除不必要的维度
y_pred = (outputs >= 0.5).float()
# 将输出的概率转化为二分类结果 (0或1)
correct = (y_pred == y).sum().item()
# 计算预测正确的数量
total_correct += correct
total_count += y.size(0)
accuracy = total_correct / total_count
print(f'Accuracy: {accuracy*100:.2f}%')
# 计算并输出准确率
def load():
with open('./preprocessed_data/word_count.pkl', 'rb') as f:
word_count = pickle.load(f)
with open('./preprocessed_data/X_test.pkl', 'rb') as f:
X_test = pickle.load(f)
with open('./preprocessed_data/y_test.pkl', 'rb') as f:
y_test = pickle.load(f)
return word_count, X_test, y_test
预测脚本2 predict_review.py
该预测脚本预测的是通过用户自行输入的评论
import pickle
import keras
import torch
from nltk import word_tokenize
from utils.data_loader import clean_text
from utils.model import sentimentmodel
def load_model():
with open('./preprocessed_data/word_to_index.pkl','rb') as f:
word_to_index = pickle.load(f)
model = sentimentmodel(len(word_to_index),1)
model.load_state_dict(torch.load('./model/sentiment_model.pt', map_location=torch.device('cpu')))
model.eval()
# 设置模型的嵌入词汇量和输出 并且加载模型 设定为评估模式
return model, word_to_index
def predict_input(model, word_to_index, review):
sentence = []
# 用来储存被转换为索引后的句子
"""review = input("Please enter your review (or type 'exit' to quit): ")
if review.lower() == 'exit':
break"""
# 如果不采用GUI界面输入的话 以上为输入方式
text = word_tokenize(clean_text(review))
# 将输入后的句子经过正则处理后分词
for word in text:
if word in word_to_index:
sentence.append(word_to_index[word])
# 如果这个词在词汇表里的话则添加
else:
sentence.append(0)
# 否则置为0
sentence = keras.preprocessing.sequence.pad_sequences([sentence], maxlen=100, padding='post')
# 将这组索引裁剪到固定的100长度 不够的用0填充
sentence = torch.tensor(sentence, dtype=torch.long).unsqueeze(0)
# 将这组索引转化为扎改良 并且增加一个维度以匹配模型的输入格式
hidden = model.init_hidden(1)
output, _ = model(sentence, hidden)
output = output.squeeze()
# 训练模型 并删除多余的维度
answer = (output>=0.5).float()
# 将答案转为二分类结果
if answer :
print('This is a positive review.')
return 'This is a positive review.'
else:
print('This is a negative review.')
return 'This is a negative review.'
# return是为了使GUI界面的弹窗接受到返回的字符串并显示
# 如果不使用GUI的话则可以删除这两个return
"""def predict_review():
model, word_to_index = load_model()
predict_input(model, word_to_index)"""
# 如果不使用GUI界面的话 则使用这函数进行调用即可
GUI界面 GUI.py
import tkinter as tk
from tkinter import messagebox
from utils.predict_review import *
def GUI():
def click_submit():
# 当用户点击Submit按钮时 调用此函数
review = text.get("1.0", tk.END)
# 获取用户输入的文本内容 从第一行第0个字符开始到结尾全部捕获
result = predict_input(model, word_to_index, review)
# 调用自己设定的那个predict_input获取结果
messagebox.showinfo("Sentiment Result", result)
# 将获取的结果显示在弹窗中
model, word_to_index = load_model()
# 加载模型和词汇表
root = tk.Tk()
root.title("IMDb Analysis")
root.geometry("300x250+575+300")
# 创建主窗口 并且设定title、大小和距离左上角的位置
text_label = tk.Label(root, text="Input your review:")
text_label.pack(pady=10)
# 创建一个标签 提示的一段文本内容 父亲为上面设置的主窗口root
# 并且设置其垂直边距为10个像素
# 也可以直接在Label(root, text="Input your review:")后面
# 添加.pack 但是不推荐 因为这样的话会使其返回'None' 这样以后就
# 无法处理和调用这个部件了 所以像我这样分开处理是非常推荐的
# 看着也工整
text = tk.Text(root, width=50, height=10)
text.pack(padx=20, pady=10)
# 创建一个多行文本输入框 宽为50 高为10
# 水平和垂直边距分别为20和10
submit_button = tk.Button(root, text="Submit", command=click_submit)
submit_button.pack(pady=10)
# 创建一个按钮 当点击按钮时调用click_submit函数
root.mainloop()
# 启动Tkinter主事件循环 设定的这个窗口开始运行并等待交互