基于HAN的餐厅评价情感分析
代码详见:https://github.com/xiaozhou-alt/HAN_Emotional-Prediction-Restaurant-Reviews
文章目录
一、项目介绍
本项目是基于大众点评的餐厅文字评论,使用HAN模型,对评论进行多方位(总体、环境、口味、服务)情感评分进行预测。
二、数据集介绍
- 下载地址: 百度网盘链接
- 数据概览: 24 万家餐馆,54 万用户,440 万条评论/评分数据
- 推荐实验: 推荐系统、情感/观点/评论 倾向性分析
- 数据来源: 大众点评
- 原数据集: Dianping Review Dataset,Yongfeng Zhang 教授为 WWW 2013, SIGIR 2013, SIGIR 2014 会议论文而搜集的数据
- 加工处理:
- 只保留原数据集中的评论、评分等信息,去除其他无用信息
- 整理成与 MovieLens 兼容的格式
- 进行脱敏操作,以保护用户隐私
具体csv文件以及参数命名信息详见 intro.ipynb
ratings.csv 格式如下所示:
数据集词云图如下:
环境、口味、服务与总评分相关的桑基图如下:
此次项目中仅使用 ratings.csv ,即用户评论,没有使用餐厅信息,读者可以在本项目基础上关联餐厅ID,实现餐厅的推荐以及多方位评分机制
三、模型介绍
层次化注意力网络(Hierarchical Attention Network, HAN)由 Yang 等学者于2016年提出,是自然语言处理领域针对长文本建模的里程碑式模型。其核心设计围绕层次化结构与注意力机制展开,通过双向 GRU(门控循环单元)依次编码“词→句子→文档”三层语义信息:
词级编码:利用双向 GRU 提取句子中每个词语的上下文特征,并通过词级注意力动态聚焦关键词汇(如“服务热情”);
句子级编码:将句子向量序列输入另一层双向 GRU,捕捉文档的全局语义关联,再通过句级注意力筛选重要句子(如负面评价段落);
多任务输出层:基于注意力加权的文档向量,可灵活适配分类、回归等任务(如同时预测评分、情感等)。该模型因层次化解构和可解释性(注意力权重可视化)被广泛应用于用户评论分析、新闻分类、医疗文本挖掘等场景。
对于HAN感兴趣的读者可以阅读:分层注意网络 HAN 介绍
模型定义:
class HierarchicalAttentionNetwork(nn.Module):
def __init__(self, vocab_size, embed_dim, word_hidden_dim, sentence_hidden_dim, dropout):
super().__init__()
# Word-level Attention
self.word_embed = nn.Embedding(vocab_size, embed_dim)
self.word_gru = nn.GRU(embed_dim, word_hidden_dim, bidirectional=True, batch_first=True)
self.word_fc = nn.Linear(2*word_hidden_dim, 2*word_hidden_dim)
self.word_context = nn.Linear(2*word_hidden_dim, 1, bias=False)
# Sentence-level Attention
self.sentence_gru = nn.GRU(2*word_hidden_dim, sentence_hidden_dim, bidirectional=True, batch_first=True)
self.sentence_fc = nn.Linear(2*sentence_hidden_dim, 2*sentence_hidden_dim)
self.sentence_context = nn.Linear(2*sentence_hidden_dim, 1, bias=False)
# Regression Heads
self.regression = nn.ModuleList([nn.Linear(2*sentence_hidden_dim, 1) for _ in range(4)])
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# x shape: (batch, sentences, words)
batch_size = x.size(0)
# Word-level processing
x = self.word_embed(x) # (batch, sent, word, emb)
x = self.dropout(x)
# Process each sentence
word_attentions = []
for sent_idx in range(x.size(1)):
sentence = x[:, sent_idx, :, :] # (batch, words, emb)
word_out, _ = self.word_gru(sentence) # (batch, words, 2*hidden)
# Word attention
word_energy = torch.tanh(self.word_fc(word_out))
word_energy = self.word_context(word_energy).squeeze(-1) # (batch, words)
word_alpha = F.softmax(word_energy, dim=1).unsqueeze(1) # (batch, 1, words)
sentence_embed = torch.bmm(word_alpha, word_out).squeeze(1) # (batch, 2*hidden)
word_attentions.append(sentence_embed)
# Sentence-level processing
sentences = torch.stack(word_attentions, dim=1) # (batch, sent, 2*hidden)
sent_out, _ = self.sentence_gru(sentences) # (batch, sent, 2*hidden)
# Sentence attention
sent_energy = torch.tanh(self.sentence_fc(sent_out))
sent_energy = self.sentence_context(sent_energy).squeeze(-1) # (batch, sent)
sent_alpha = F.softmax(sent_energy, dim=1).unsqueeze(1) # (batch, 1, sent)
document_embed = torch.bmm(sent_alpha, sent_out).squeeze(1) # (batch, 2*hidden)
# Regression outputs
outputs = [head(document_embed) for head in self.regression]
return torch.cat(outputs, dim=1)
模型定义的核心实现了一个 层次化注意力网络(Hierarchical Attention Network, HAN),包含以下关键设计:
双向GRU编码:通过词级(word_gru)和句级(sentence_gru)双向门控循环单元,分别捕捉词语上下文关系和句子间依赖;
注意力机制:词级注意力(word_context)聚焦句子中的关键词,句级注意力(sentence_context)定位文档中的重要句子;
多任务输出:使用独立回归头(regression 模块列表)同时预测总评分、环境、口味、服务四个维度的分数;
层次化结构:输入经词嵌入层(word_embed)处理后,依次通过「词→句子→文档」三级表征学习,最终生成包含语义权重的文档向量(document_embed)。
四、项目实现
1.文件夹格式
HAN_Emotional-Prediction-Restaurant-Reviews/
├── checkpoints/ # 模型保存目录(二进制数据)
├── configs/
│ └── base.yaml # YAML格式配置文件
├── data/
│ ├── processed/ # 处理后数据目录
│ ├── intro.ipynb # 数据介绍
│ └── ratings.csv # CSV格式原始数据
├── img/ # 图片资源目录
├── logs/ # 训练日志目录
├── models/
| └── han.py # 模型定义目录
├── preprocessed/
│ └── preprocess.py # 数据预处理
├── utils/
│ ├── __pycache__/
│ ├── data_loader.py # 数据加载工具
│ ├── metrics.py # 评估指标计算
├── evaluate.py # 模型评估脚本
├── predict.py # 预测脚本
├── README.md
└── train.py # 主训练脚本
2.数据预处理
1) 分句处理
使用滑动窗口的标点识别分句:
def split_sentences(text):
"""分句函数优化版"""
delimiters = {'。', '!', '?', ';', ',', '…','~'}
sentences = []
buffer = []
for char in text:
buffer.append(char)
if char in delimiters:
sentences.append(''.join(buffer).strip())
buffer = []
if buffer:
sentences.append(''.join(buffer).strip())
return sentences
2) 读入+清洗
由于评论列( comment )可能过长导致报错,此处解除解析的长度限制,并对过长文本进行批次读入的处理:
# 读入csv并解除长句限制
max_int = sys.maxsize # 动态调整CSV解析限制
while True:
try:
csv.field_size_limit(max_int)
break
except OverflowError:
max_int = int(max_int/10)
对读入后的数据进行清洗,统一分隔符,截取需要的评分列信息,去除评论空值的条目并对评分数据值进行验证和转化防止后续处理中出错(此处没有完全使用全部的440万条数据,仅仅使用了50万条数据,读者可根据自己的需求自行修改):
# 限制处理行数为50万
raw_lines = raw_lines[:500000]
# 数据清洗管道
cleaned_lines = []
for line in tqdm(raw_lines, desc="清洗数据行"):
line = line.strip() \
.replace('"', '') \
.replace(',', '\t') # 统一分隔符
# 列数据验证增强
parts = line.split('\t')[:8] # 强制截断到8列
if len(parts) < 8:
parts += [''] * (8 - len(parts))
# 数值列验证(第3-6列为评分)
for i in range(2,6):
try:
parts[i] = str(float(parts[i]))
except:
parts[i] = ''
cleaned_lines.append('\t'.join(parts[:8]))
3) 文本处理
创建评分列,验证评分数据的合法性并清除异常项和空值;对评论文本进行分句处理并进行数据编码:
# 创建DataFrame
column_names = [
"userId", "restId", "rating", "rating_env",
"rating_flavor", "rating_service", "timestamp", "comment"
]
df = pd.read_csv(
StringIO('\n'.join(cleaned_lines)),
sep='\t',
header=None,
names=column_names,
engine='python',
quoting=csv.QUOTE_NONE,
dtype={'comment': str},
error_bad_lines=False,
warn_bad_lines=True,
na_values=['', 'NA','null']
)
...
# 过滤空值并转换数值类型
df_cleaned = df.dropna(subset=rating_columns, how='all')
for col in rating_columns:
df_cleaned[col] = pd.to_numeric(df_cleaned[col], errors='coerce')
df_cleaned = df_cleaned.dropna(subset=rating_columns, how='any')
# 评分范围验证
df_cleaned = df_cleaned[
df_cleaned[rating_columns].apply(
lambda x: x.between(1,5).all() & x.notnull().all(),
axis=1
)
]
...
# 构建数据集
comments = df_cleaned['comment'].tolist()
ratings = df_cleaned[rating_columns].to_numpy(dtype=np.float32)
# 分词处理优化
word_counter = Counter()
processed_docs = []
with tqdm(total=len(comments), desc="处理评论") as pbar:
for comment in comments:
if not isinstance(comment, str) or not comment.strip():
processed_docs.append([[1]]) # <UNK>标记
pbar.update(1)
continue
sentences = split_sentences(comment)[:config['data']['max_sentences']]
doc = []
for sent in sentences:
# 分词优化:过滤非中文字符
words = [
w.strip() for w in jieba.cut(sent)
if w.strip() and re.match(r'[\u4e00-\u9fa5]', w)
][:config['data']['max_words']]
if words:
word_counter.update(words)
doc.append(words)
processed_docs.append(doc or [[1]])
pbar.update(1)
word2idx = {w:i for i,w in enumerate(vocab)}
# 数据编码
final_data = []
for doc in tqdm(processed_docs, desc="编码文档"):
encoded_doc = []
for sent in doc[:config['data']['max_sentences']]:
indices = [word2idx.get(w,1) for w in sent][:config['data']['max_words']]
indices += [0]*(config['data']['max_words']-len(indices))
encoded_doc.append(indices)
# 填充空句子
encoded_doc += [[0]*config['data']['max_words']]*(config['data']['max_sentences']-len(encoded_doc))
final_data.append(encoded_doc)
双层级处理:
- 文档级:最大句子数限制(config[‘max_sentences’])
- 句子级:
- jieba分词 → 过滤非中文字符
- 最大词数限制(config[‘max_words’])
词汇表构建:
vocab = [< PAD >,< UNK >] + 高频词
word2idx = { 词 : 索引 } 映射
< PAD >: 填充标记(索引 0)
< UNK >: 未知词标记(索引 1)
4) 保存pkl文件
os.makedirs(config['data']['processed_dir'], exist_ok=True)
X_train, X_test, y_train, y_test = train_test_split(
final_data, ratings,
test_size=config['training']['test_size'],
random_state=42
)
# 保存结果
with open(os.path.join(config['data']['processed_dir'], 'train.pkl'), 'wb') as f:
pickle.dump({'X':X_train, 'y':y_train}, f)
with open(os.path.join(config['data']['processed_dir'], 'test.pkl'), 'wb') as f:
pickle.dump({'X':X_test, 'y':y_test}, f)
with open(os.path.join(config['data']['processed_dir'], 'vocab.pkl'), 'wb') as f:
pickle.dump(word2idx, f)
保存格式:(pickle 序列化存储)
- train.pkl : 训练集(X: 三维数组[样本, 句子, 词], Y: 评分)
- test.pkl : 测试集
- vocab.pkl : 词表字典
3.训练
1) 定义数据集类
class RatingDataset(Dataset):
def __init__(self, data_path):
with open(data_path, 'rb') as f:
data = pickle.load(f)
if isinstance(data['y'], np.ndarray) and data['y'].dtype == np.object_:
self.y = torch.FloatTensor(data['y'].astype(np.float32))
else:
self.y = torch.FloatTensor(data['y'])
self.X = torch.LongTensor(data['X'])
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
return self.X[idx], self.y[idx]
数据结构:
X: 3D张量 [batch_size, max_sentences, max_words]
y: 2D张量 [batch_size, 4] (总评分 + 环境/口味/服务评分)
2) 数据加载和评估指标函数
使用 PyTorch 原生 DataLoader ,支持自动批处理,batch_size 从配置文件中读取;R²分数衡量模型对数据方差的解释能力:
def get_dataloader(data_path, batch_size, shuffle=True):
dataset = RatingDataset(data_path)
return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle)
def calculate_r2_score(y_true, y_pred):
# 手动实现R²计算
ss_total = np.sum((y_true - np.mean(y_true))**2)
ss_res = np.sum((y_true - y_pred)**2)
return 1 - (ss_res / ss_total)
3) 训练启动
读入 config 配置文件,训练 pkl 以及词表字典:
def train():
# Load config
with open("configs/base.yaml") as f:
config = yaml.safe_load(f)
# Prepare data
train_loader = get_dataloader(
"data/processed/train.pkl",
config['training']['batch_size']
)
with open("data/processed/vocab.pkl", "rb") as f:
vocab = pickle.load(f)
配置文件 base.yaml 如下:
data:
raw_path: "data/ratings.csv"
processed_dir: "data/processed/"
max_sentences: 10
max_words: 20
vocab_size: 20000
model:
embed_dim: 300 # 词向量维度
word_hidden_dim: 128 # 词级GRU隐藏层
sentence_hidden_dim: 128 # 句子级GRU隐藏层
dropout: 0.5
training:
batch_size: 32
epochs: 10
lr: 0.001
test_size: 0.2
模型构建参数(关于HAN的介绍详见上方模型介绍):
model = HierarchicalAttentionNetwork(
vocab_size=len(vocab),
embed_dim=config['model']['embed_dim'],
word_hidden_dim=config['model']['word_hidden_dim'],
sentence_hidden_dim=config['model']['sentence_hidden_dim'],
dropout=config['model']['dropout']
)
训练循环:
for epoch in range(config['training']['epochs']):
model.train()
# 批处理流程...
outputs = model(X_batch) # [batch_size,4]
loss = sum([criterion(outputs[:,i], y_batch[:,i]) for i in range(4)])
- 多任务损失:同时对四个评分计算 MSE 并求和
- 设备管理:自动检测 GPU 可用性(to(device))
将训练损失( loss )以及每一个 epoch 记录到 xlsx文件;保存最佳模型文件:
# 记录到DataFrame
new_row = pd.DataFrame({
'Epoch': [epoch+1],
'Loss': [avg_loss],
'MSE': [avg_mse],
'MAE': [avg_mae],
'R2': [r2]})
metrics_df = pd.concat([metrics_df, new_row], ignore_index=True)
# 保存到Excel
if epoch == 0:
metrics_df.to_excel(excel_path, index=False)
else:
with pd.ExcelWriter(excel_path, mode='a', engine='openpyxl', if_sheet_exists='overlay') as writer:
metrics_df.iloc[[-1]].to_excel(writer, index=False, header=False, startrow=epoch+1)
tb_writer.add_scalar('Loss/train', avg_loss, epoch)
print(f"Epoch {epoch+1}, Loss: {avg_loss:.4f}")
# Save checkpoints
torch.save(model.state_dict(), f"checkpoints/epoch_{epoch+1}.pth")
# Save best model
if avg_loss < best_loss:
best_loss = avg_loss
torch.save(model.state_dict(), "checkpoints/best_model.pth")
print(f"New best model saved with loss {best_loss:.4f}")
训练过程截图:
4.评估
运行评估代码(evaluate.py)输出四个方面在测试集上的最终四个指标:
使用 xlsx 中的数据绘制绘制训练的每一个 epoch 的指标折线图如下所示:
本次项目仅训练了40轮,可以看出效果还算不错,下面来看看直接输入评论的效果
5.推理预测
运行推理预测代码,手动输入评论然后得到评分如下:
可以看出模型对于评论评分已经有了初步的较为不错的判断。其中总体评分相对来说,会让人直观感觉较高,但是通过对数据集的观察,发现数据集中总评几乎都优于其余各项评分的平均,所以不能单纯依此来判断模型推断的优劣。
如果你喜欢我的文章,不妨给小周一个免费的点赞和关注吧!