使用 Bert + ResNet101 混合模型处理多模态酒店评论

此处特别感谢一位不愿意透漏姓名的小酥同学,为本文的创作提供Bert模型训练代码,以处理文本数据


代码详见:https://github.com/xiaozhou-alt/Hotel-reviews-Sentiment-classification


一、项目介绍

本项目基于多模态深度学习的情感分析系统,融合图像与文本双模态输入,通过改进的 ResNet101 (集成通道注意力机制) 解析酒店环境图片,实现八类主题属性识别和情感倾向检测,同时结合微调的中文 Bert 模型对评论文本进行六个维度的细粒度情感分析,因为使用的是文本和图像分别训练模型,然后综合计算得到情感倾向,所以项目支持图像、文本独立或联合推理。项目采用多任务学习架构,具备模块化设计、注意力增强、概率融合决策等技术特性,并采用 Flask 框架构建 Web 服务。

二、数据集介绍

数据集下载地址:
链接: https://pan.baidu.com/s/1Z7gbhD00984h53XRaPIpaQ?pwd=r2xn 提取码: r2xn

数据集以及训练代码文件夹结构如下(ps:本项目为便于直接部署使用HTML网页,包含训练文件夹和推理网页文件夹):

Qunar/
├── bert_base_chinese/
│   ├── config.json              # 模型配置文件
│   ├── pytorch_model.bin        # PyTorch模型权重
│   ├── tokenizer_config.json    # 分词器配置
│   └── vocab.txt                # 词汇表
├── data/                        # 数据集目录
│   ├── download_pic_02.csv      # 图片下载记录
│   ├── multi.csv                # 多模态数据集
│   ├── object.csv               # 对象检测数据
│   ├── picture.csv              # 图片元数据
│   └── review.csv               # 评论文本数据
├── saved_models/                # 模型保存目录
│   ├── best_sentiment_acc_51.26.pth  # 最佳情感分析模型
│   └── bert_model.pth           # 微调之后的Bert模型
├── bert.py                      # Bert模型脚本
├── pic.ipynb                    # 图片处理训练
└── README-data.md               # 数据集说明文档

README-data.md 展示如下:

文件描述
pic.tar 解压后为jpg图片文件夹
multi.csv 包含文本描述及多模态标注(标注者同时根据文本和图像进行情感标注)
object.csv 包含对图像中ROI对象的情感标注
picture.csv 包含对图像的情感标注
review.csv 包含对文本的情感标注
标注描述
Aspect Category: 区位,餐饮,房间设施,娱乐设施,店内设施,服务,风格化,安全,卫生,价格
Sentiment: 0(未涉及)、1(积极)、2(中性)、3(消极)
数据集收集
我们从去哪儿网收集了120,000余条酒店领域的用户评价数据,去哪儿是全球最大的中文在线旅行网站,提供酒店在线预定服务。由于用户对同一家酒店的评论和图片是极为相关的,为了减少因此而带来的特异性,我们从中筛选出36,000条用户评论数据。从这些评论数据中,我们选取了图片数量在4~6张、文本为中文、文本长度在10-512长度的用户评论。最终,我们收集了3,518条酒店评论文本,附带15,825张图片作为我们的待标注数据,通过Facebook提供的detectron工具对图片进行目标检测得到了35,997个图片实体。
酒店其他相关数据是一个覆盖更多评论的数据集,供有余力的同学探索分析。
数据集标注
对于多模态任务,我们预定义了6个酒店领域的类别关注方面:区位、餐饮、房间设施、娱乐设施、店内其他设施、服务,其中房间设施指酒店客房内相关设施,娱乐设施指酒店中提供的游泳池、健身房等娱乐相关设施,店内其他设施指酒店内大厅、走廊、电梯等酒店内部公共区域的相关设施。六个方面的类别都可从图文两种模态有较为直观的判断,类别间有清晰的划分。每位标注者首先需要判断经目标检测得到的图片实体是否正确,然后对于每张图片和图片中正确识别的实体被要求标注与六个类别是否相关{0,1},而对于文本则标注不相关、积极、中性、消极{0,1,2,3}四种标签,多模态数据整体的标注与文本相同。

以下是多模态数据集 multi.csv 各个主题的情感倾向数量统计:
在这里插入图片描述

三、项目实现

1.Bert模型处理文本评论

1) Bert模型介绍

BERT(Bidirectional Encoder Representations from Transformers)是由Google于2018年提出的基于Transformer架构的预训练语言模型,标志着自然语言处理领域进入预训练大模型时代。其核心创新在于通过双向Transformer编码器捕捉上下文语义,突破了传统单向语言模型的限制。BERT采用掩码语言建模(MLM)和下一句预测(NSP)两大预训练任务,在大规模无标注文本(如Wikipedia)上学习通用语言表示,通过随机遮蔽15%的词汇要求模型还原原始文本,同时判断句子间的逻辑关系。这种预训练范式使模型能够捕获词汇、句法和语义的多层次特征,通过微调即可适配文本分类、问答、实体识别等下游任务。
在这里插入图片描述

2) 预训练模型下载及配置

代码中实际上包含从 HuggingFace 官网下载模型的逻辑,但是考虑到国内网络可能请求不到,所以可以使用 HF-Mirror 镜像源下载预训练模型:https://hf-mirror.com/google-bert/bert-base-chinese
需要下载的文件详见上方 bert_base_chinese 文件夹结构

3) 读入数据

class ReviewDataset(Dataset):
    def __init__(self, df, tokenizer, max_len=128):
        self.df = df
        self.tokenizer = tokenizer
        self.max_len = max_len
        self.texts = df['review'].values
        self.labels = df[aspects].values.astype(float)

    def __len__(self):
        return len(self.df)

    def __getitem__(self, idx):
        text = self.texts[idx]
        labels = self.labels[idx]

        encoding = self.tokenizer.encode_plus(
            text,
            add_special_tokens=True,
            max_length=self.max_len,
            return_token_type_ids=False,
            padding='max_length',
            truncation=True,
            return_attention_mask=True,
            return_tensors='pt',
        )

        return {
            'input_ids': encoding['input_ids'].flatten(),
            'attention_mask': encoding['attention_mask'].flatten(),
            'labels': torch.tensor(labels, dtype=torch.float)
        }
# 标签二值化处理
def label_binarize(x):
    return 0 if x==0 else 1

aspects = ['区位', '餐饮', '房间设施', '娱乐设施', '店内设施', '服务']
for col in aspects:
    df[col] = df[col].apply(label_binarize)

# 构建训练数据
df['review'] = df['review_words'].apply(lambda x: ''.join(eval(x)))
train_df = df[['review'] + aspects]
  • 将评分列转换为二元标签(0/1)
  • 拼接评论文本中的分词结果
  • 构建包含文本和所有标签列的DataFrame

4) 模型架构

class BertClassifier(nn.Module):
    def __init__(self, n_classes):
        super().__init__()
        self.bert = BertModel.from_pretrained('bert-base-chinese')
        self.drop = nn.Dropout(p=0.3)
        self.out = nn.Linear(self.bert.config.hidden_size, n_classes)

    def forward(self, input_ids, attention_mask):
        pooled_output = self.bert(...)[1]  # 获取[CLS]标记
        return self.out(self.drop(pooled_output))
  • 使用BERT的[CLS]标记作为分类特征
  • 添加Dropout层防止过拟合(p=0.3)

5) 训练

def train_epoch(model, data_loader, criterion, optimizer):
    model.train()
    losses = []
    for batch in data_loader:
        input_ids = batch['input_ids'].to(device)
        attention_mask = batch['attention_mask'].to(device)
        labels = batch['labels'].to(device)
        outputs = model(input_ids, attention_mask)
        loss = criterion(outputs, labels)
        losses.append(loss.item())
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    return np.mean(losses)
  • 优化器:AdamW(学习率2e-5)
  • 损失函数:BCEWithLogitsLoss(适用于多标签分类)
  • Batch Size:16
  • 训练策略​​:
    • 使用pin_memory=True加速GPU数据传输
    • 每批次结束后立即梯度清零

6) 评估

def eval_model(model, data_loader):
    model.eval()
    predictions = []
    real_labels = []
    with torch.no_grad():
        for batch in data_loader:
            input_ids = batch['input_ids'].to(device)
            attention_mask = batch['attention_mask'].to(device)
            labels = batch['labels'].to(device)
            outputs = model(input_ids, attention_mask)
            preds = torch.sigmoid(outputs).cpu().numpy()
            predictions.extend(preds)
            real_labels.extend(labels)

    predictions = np.array(predictions) >= 0.5
    accuracy = accuracy_score(real_labels, predictions, normalize=True, sample_weight=None, multilabel=True)
    f1 = f1_score(real_labels, predictions, average='macro')
    return accuracy, f1
  • 准确率:整体预测正确的比例
  • Macro-F1:各类别F1的平均值

7) 预测推理

def predict(text, model, tokenizer):
    encoding = tokenizer.encode_plus(
        text,
        max_length=128,
        padding='max_length',
        return_tensors='pt'
    )
    outputs = model(...)
    return {aspect: int(pred >= 0.5) for ...}

直接调用函数输入评论语句 text 进行主题预测和情感倾向判断
以下是输出展示:
在这里插入图片描述
在评论数据集 review.csv 上态度为积极的数量和 满意度
在这里插入图片描述

2.ResNet-101处理图片评论

1) ResNet 模型介绍

ResNet101ResNet 系列中深度较大的网络结构,具有101层,核心设计思想是通过​​残差块(Residual Block)​​ 解决深度网络训练中的梯度消失和退化问题。
​​残差块设计​​:每个残差块包含两个3×3卷积层(基础版本)或​​ Bottleneck 结构​​(1×1卷积降维 → 3×3卷积 → 1×1卷积升维),后者用于深层网络以降低计算量。
​​阶段划分​​:包含4个阶段(Stage),每阶段由多个残差块构成(例如stage3包含23个 Bottleneck 块)。
​​跳跃连接​​:通过恒等映射(Identity Mapping)将输入直接传递到输出,使网络可学习残差函数而非原始映射。
​​全局平均池化​​:取代传统全连接层,减少参数量的同时保持空间信息。

在这里插入图片描述
ResNet101 与传统 ResNet 的对比:

维度ResNet101(深层网络)ResNet18/34(浅层网络)
残差块类型使用Bottleneck结构(3层卷积)基础残差块(2层3×3卷积)
​​参数量约44.6M参数,计算复杂约11.7M参数(ResNet18)
适用场景大型数据集、复杂特征提取(如ImageNet)小数据集、资源受限场景
​​训练需要更多显存和优化技巧训练速度快、收敛稳定

注意力机制 的引入:
ResNet101layer1layer2layer4 后插入通道注意力机制,其优势体现在以下方面:
1. 特征选择增强
​​通道动态加权​​:通过平均池化和最大池化双路径提取通道统计信息,生成注意力权重,强化重要通道的特征响应(例如突出病毒图像中的关键纹理)。
​​抑制噪声干扰​​:在情感分类等任务中,可过滤背景噪声(如无关的皮肤区域)。
2. 性能提升方向
​​多任务适应性​​:不同任务(主题分类8头、情感分类3头)可通过注意力机制自主调整特征重要性,避免任务间特征干扰。
​​计算效率优化​​:注意力模块仅增加少量参数(如256通道的CA模块参数量为 (256/16) * 256 * 2 ≈ 8192),但能显著提升模型鲁棒性。
3. 与ResNet的协同效应
​​残差 + 注意力的互补​​:ResNet的跳跃连接保障梯度传播,注意力机制则优化特征分布,二者结合在医学图像分类等任务中可实现 99%+准确率。
​​迁移学习友好性​​:冻结底层参数后,注意力模块可微调高层特征,比单纯解冻层 3/4更高效(如后续代码中仅训练 layer3 / layer4

2) 环境准备(GPU、TensorFlow、PyTorch

import tensorflow as tf
import torch

# 检查 TensorFlow
print("\n=== TensorFlow 信息 ===")
print(f"版本: {tf.__version__}")
print(f"GPU 可用性: {'是' if tf.config.list_physical_devices('GPU') else '否'}")

# 检查 PyTorch
print("\n=== PyTorch 信息 ===")
print(f"版本: {torch.__version__}")
print(f"CUDA 可用性: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"当前 GPU 设备: {torch.cuda.get_device_name(0)}")

输出示例如下所示:

=== TensorFlow 信息 ===
版本: 2.16.1
GPU 可用性: 是

=== PyTorch 信息 ===
版本: 2.3.1+cu121
2025-04-05 21:05:53.018498: I external/local_xla/xla/stream_executor/cuda/cuda_executor.cc:998] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at https://github.com/torvalds/linux/blob/v6.0/Documentation/ABI/testing/sysfs-bus-pci#L344-L355

CUDA 可用性: True
当前 GPU 设备: NVIDIA A10
2025-04-05 21:05:53.028741: I external/local_xla/xla/stream_executor/cuda/cuda_executor.cc:998] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero. See more at https://github.com/torvalds/linux/blob/v6.0/Documentation/ABI/testing/sysfs-bus-pci#L344-L355

此处笔者因为便捷直接使用了modelscpoe的云服务器GPU,并未使用本人电脑的NVIDIA中的CUDA。其实是因为电脑太垃圾以及配置GPU环境有点麻烦

3) 数据清洗与验证

图片数据集 pic.csv 中存在少部分图片名称(path)与真实图片名称不符合的情况,所以我们首先进行清洗验证,排除错误数据干扰,得到有效数据数量

def clean_dataset(csv_path, img_dir):
    df = pd.read_csv(csv_path)
    # 过滤无效图片路径
    valid_paths = []
    for idx, row in df.iterrows():
        img_path = os.path.join(img_dir, row['path'])
        if os.path.exists(img_path):
            ...
        else:
            valid_paths.append(False)
    df = df[valid_paths]
    print(f"清洗后有效数据量: {len(df)}")
    return df

输出如下所示:

清洗后有效数据量: 16521

4) 划分数据集

定义酒店图片数据类,将图像进行数据集按照 4:1 划分为训练集和验证集,其中包含了处理图像异常值和标签错误的方法

class HotelDataset(Dataset):
    def __init__(self, df, img_dir, transform=None):
        self.df = df
        ...
        
    def __len__(self):
        return len(self.df)
    
    def __getitem__(self, idx):
        row = self.df.iloc[idx]
        img_path = os.path.join(self.img_dir, row['path'])
        ...
        return image, {
            'themes': theme_labels,
            'sentiment': sentiment_label
        }
...
print("训练集样本数:", len(train_df))  # 应约 13216
print("验证集样本数:", len(val_df))    # 应约 3305
train_dataset = HotelDataset(train_df, img_dir)
val_dataset = HotelDataset(val_df, img_dir)

输出如下所示:

训练集样本数: 13216
验证集样本数: 3305

5) 模型定义

下载 ResNet101 预训练模型,定义注意力机制ResNet101 的模型架构

class ChannelAttention(nn.Module):
    def __init__(self, in_channels, reduction_ratio=16):
        super().__init__()
        ...
    def forward(self, x):
        ...
class ResNet101Att(nn.Module):
    def __init__(self):
        super().__init__()
        base_model = models.resnet101(weights=ResNet101_Weights.IMAGENET1K_V1) # 下载预训练模型
        # 完整的特征提取部分
        self.features = ...
        # 多任务输出头
        self.avgpool = base_model.avgpool  # [batch, 2048, 1, 1]
        # 主题分类头(8个二分类)
        self.theme_head = ...
        # 情感分类头(三分类)
        self.sentiment_head = ...
        ...
    def forward(self, x):
        ...
        return {
            'themes': self.theme_head(x),
            'sentiment': self.sentiment_head(x)
        }

6) 训练

值得一提的是,损失计算中因为我们同时输出主题预测和情感预测两个方向,所以损失值涉及到权重分配,此处选用权重为 7:3,读者可以自行调整

# 训练循环
for epoch in range(10):
    model.train()
    ...
    # 训练循环中的损失计算部分
    for inputs, labels in train_loop:
        ...
        # 损失计算(此处设置的比例是 主题:情感倾向=7:3)
        loss = 0.7 * criterion['themes'](outputs['themes'], theme_labels) \
               + 0.3 * criterion['sentiment'](outputs['sentiment'], sentiment_labels) 
    ...
    # 验证
    model.eval()
    ...
    val_loop = tqdm(
        val_loader,
        desc=f"Epoch {epoch+1}/10 [Val]",
        total=len(val_loader),
        leave=False
    )
    with torch.no_grad():
        for inputs, labels in val_loop:
            ...

训练过程输出如下所示:

Epoch 1, Theme Acc: 89.22%, Sentiment Acc: 46.99%
Saved epoch model: saved_models/epoch_1_theme_89.22_sentiment_46.99.pth
New best sentiment model saved: saved_models/best_sentiment_acc_46.99.pth

7) 推理测试

import matplotlib.pyplot as plt  

def load_model(checkpoint_path, device='cuda'):
    """加载训练好的模型"""
    ...
    return model

def preprocess_image(image_path):
    """图像预处理"""
    transform = ...
    return transform(image).unsqueeze(0)

# 4. 预测函数
def predict(image_path, model, device='cuda'):
    # 预处理
    input_tensor = preprocess_image(image_path).to(device)
    ...
    return {
        'theme_probs': dict(zip(theme_names, theme_probs.tolist())),
        'main_theme': main_theme,
        'sentiment': sentiment_labels[sentiment_id],
        'sentiment_probs': {
            '消极': sentiment_probs[0],
            '中性': sentiment_probs[1],
            '积极': sentiment_probs[2]
        }
    }
# 5. 使用示例
if __name__ == "__main__":
    ...
    print("\n预测结果:")
    print("="*40)
    print("主题概率分布:")
    for name, prob in results['theme_probs'].items():
        print(f"{name.ljust(8)}: {prob*100:.1f}%")
    print(f"\n主要相关主题: {results['main_theme']}")
    print("\n情感倾向:")
    print(f"预测类别: {results['sentiment']}")
    print("概率分布:")
    for k, v in results['sentiment_probs'].items():
        print(f"{k.ljust(6)}: {v*100:.1f}%")

输出如下所示:
在这里插入图片描述

预测结果:
========================================
主题概率分布:
区位 : 11.7%
餐饮 : 11.2%
房间设施 : 19.3%
娱乐设施 : 11.3%
店内设施 : 12.6%
服务 : 11.2%
风格化 : 11.5%
其他 : 11.1%
主要相关主题: 房间设施

情感倾向:
预测类别: 中性 (0)
概率分布:
消极 : 15.0%
中性 : 49.7%
积极 : 35.4%

以下是手动选择三张图片之后,分析得到的用于展示概率分布的雷达图:
在这里插入图片描述

3.推理与网页使用

本次项目将推理部分代码集成为网页,可以直接运行代码访问网页使用
推理网页文件夹格式如下:

hotel-infer/
├── model/
│   ├── bert_base_chinese/
│   │   ├── config.json
│   │   └── pytorch_model.bin
│   │   └──tokenizerconfig.json
│   │   └──tokenizer.json
│   │   └──vocab.txt
│   ├── bert_model.pth       
│   └── best_sentiment_acc_51.95.pth
├── static/
│   ├── css/
│   │   └── style.css
│   ├── js/
│   │   └── app.js          
│   └── uploads/
├── templates/
│   └── index.html
├── app.py                   # Flask主程序
├── README.md                # 项目说明文档
├── README-src.md            # 代码专项说明
└── requirements.txt          # 依赖库列表

三层次样式结构搭建一个简易网站:

  • ​​Bootstrap框架​​:提供响应式布局基础
  • 外部样式表​​:模块化定制样式
  • 内联样式​​:页面专属布局定义

index.html 文件:

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Qunar数据集多模态分析系统</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    <style>
       ...
        @keyframes fadeInUp {
            ...}
    </style>
</head>
<body>
    <div class="center-wrapper">
        <div class="main-container">
            <h2>🏨 Qunar酒店数据集多模态分析系统</h2>
            <form method="POST" action="/predict" enctype="multipart/form-data" id="analysisForm">
                ...
            </form>

            <div id="result"></div>
        </div>
    </div>
    <script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

网页效果图:
在这里插入图片描述
在这里插入图片描述
如果你喜欢我的文章,不妨给小周一个免费的点赞和关注吧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值