使用DeBERTa V3进行基于方面的情感分析(ABSA)

基于方面的情感分析(Aspect Based Sentiment Analysis, ABSA)是一种细粒度的情感分析任务,即,对于给定的一段文本,识别出该文本针对文中指定的某一方面的情感极性。

本文将介绍如何在本机上部署并使用预训练的DeBERTa V3模型对文本中的某一特定方面进行情感分析,从而实现观点挖掘相关的工作。当然,由于本人没有涉足过NLP的具体研究,可能很多表述并不严谨,敬请批评指正。

前言

这两天接触了一下情感分析相关的工作,大致是需要确定一些文本对某些个体的情感倾向。经过搜索,这属于情感分析(Sentiment Analysis)的任务范畴,而更具体地,实际上是基于方面的情感分析(Aspect Based Sentiment Analysis, ABSA)

所谓基于方面的情感分析,我们用一个例子来说明就可以。

The food here is delicious, but the serivce is terrible.

如果上面这句话是某个顾客对某家餐馆的评价,那么我们应该如何精确地描述这名顾客对餐馆的观点呢?

一个很显然的结论是,恐怕我们不能简单地将 deliciousterrible 作为顾客的态度代表,也不能简单地因为文本中出现了一个积极的词语和一个消极的词语,分别赋分1和-1,然后加起来得分为0,结论为情感中性。

但如果现在有一位饥肠辘辘的游人来到了这座城市,他更在乎食物的口味,但却对餐馆的服务态度毫不在意。现在,他正在手机上搜寻附近口味出众的餐馆,根据上述顾客的评论,我们能否将这家餐馆推荐给他呢?

通过上述这个案例,我们会发现,在现实中,我们面对的大量文本可能会同时描述多个实体(Entity),例如上面出现在同一句话中的 foodserivce ,并表示出出截然相反的态度。然而,传统的方法无法准确地判别出隐藏在文本中的上述情感差异。

而通过对文本中不同的实体进行情感分析,就有可能发现人们在观点/意见(Opinion)上的差异。在上述例子中,顾客的评价是对餐馆不同方面/实体的观点存在差异,现实中包括新闻、媒体、文章等,可能都会对不同的事物存在不同的态度。通过发掘,可能发现上述差异,从而辅助决策。

实现方法

搜索ABSA相关的应用方法,首先发现的是Azure提供的Sentiment analysis and opinion mining服务,刚好免费申请的Azure for Students权益包里似乎也能用这个API,只要把需要分析的文本上传,就可以得到分析的结果。

但思来想去,觉得光是调用API就没意思了。

继续搜索,发现HuggingFace上有很多训练好的模型,仔细一看,推理使用到的计算硬件资源也不大,在可以接受的范围内,于是决定试试在自己电脑上部署模型进行推理。

只是奈何自己也不会什么模型,最后还是得用其他人做好的模型来完成这项工作。不过倒也可以之后再改进吧。

这里我们使用的是DeBERTa V3模型,HuggingFace上已经有人使用ABSADatasets训练好了可用于ABSA任务的模型,请见yangheng/deberta-v3-large-absa-v1.1.

可以参考的其他模型

在情感分析方面,当然也有一些其他预训练好的模型可以使用,例如:

部署细节

下面大致介绍一下本地部署模型的相关步骤和可能踩坑的地方。

获取模型

确保Git已经启用LFS扩展,然后执行如下命令:

1
git clone https://huggingface.co/yangheng/deberta-v3-large-absa-v1.1

如果不希望在克隆仓库的时候下载所有大文件(如你所见,仓库中的模型文件不止一个),请设置环境变量GIT_LFS_SKIP_SMUDGE=1再运行上述git命令。

克隆了仓库的基本形态后,本地目录下的大文件将用一个较小的指针文件替代,此时,如果希望拉取远程仓库中的某一指定文件,请按以下步骤进行:

1
2
git config lfs.fetchinclude "<xxx>"
git lfs pull

在这里,请将第一行代码中的<xxx>替换成为希望下载的指定文件名称。例如,在我们使用的这一模型仓库里,我们需要拉取的是model.safetensorsspm.model两个文件,则需要执行:

1
2
3
git config lfs.fetchinclude "model.safetensors"
git config lfs.fetchinclude "spm.model"
git lfs pull

为什么拉取model.safetensors文件

答:这我也不懂,但似乎当目录中存在model.safetensors文件时,则不会主动加载pytorch_model.bin文件,而由于前者是一个没有有效数据内容的指针文件,因此加载会出错。

如果清楚safetensors格式的工作原理,请自行调整,反正能动起来就是万事大吉的。

本机环境

为了在本机上进行推理,需要创建一个Python环境,依赖如下:

这两个就是最简单的,使用CPU进行推理的配置了。稍后我们介绍不同模式时会再介绍其他模式下依赖的Python包。

运行推理

回到上面克隆的模型仓库目录的同一级目录下,创建一个.py文件(或者.ipynb,如果你喜欢),这里取名run.py。大致文件结构如下:

1
2
3
- MyABSA
- deberta-v3-large-absa-v1.1/
- run.py

编辑run.py文件,填入以下代码:

1
2
3
4
5
6
7
8
9
10
from transformers import AutoTokenizer, AutoModelForSequenceClassification, pipeline

model_path = "deberta-v3-large-absa-v1.1"

model = AutoModelForSequenceClassification.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)
recognizer = pipeline("text-classification", tokenizer=tokenizer, model=model)

sentence = "[CLS] The food here is delicious but the service is awful. [SEP] terrible [SEP]"
recognizer(sentence)

这里我们传入模型的输入分为两个部分,前半段即为本文开头的餐馆评价示例,而后面,用[SEP]标志和主要文段内容分割开的,就是我们希望探知的情感的实体。

在上面的输入中,我们希望模型给出,前面语句对于service这个实体的态度。

运行上述代码,或许你会得到类似这样的输出:

1
2
3
[[{'label': 'Negative', 'score': 0.9997419714927673},
{'label': 'Neutral', 'score': 0.00018644658848643303},
{'label': 'Positive', 'score': 7.166137220337987e-05}]]

结果不难解读,模型给出的推理认为,前面的文本对service一词表现出的消极态度占据最主导的地位。

WebAPI化

为了便于对外设计接口,还可以将推理任务设计成WebAPI,对外暴露服务。

测试了一下使用ONNX Runtime进行的部署,甚至还测试了使用DirectML加速的ONNX Runtime,但似乎没啥用,测试下来尽管GPU占用是有了,但速度甚至比用numpy在CPU上的推理还要慢了。很大可能是我代码写的不好,数据在CPU和GPU之间反复倒腾了,导致速度上不去。

总之这一部分的代码,首先需要导出模型为ONNX格式,然后再放到这里进行加载。其实这部分内容主要是我想亲自动手看看,在pipeline中间到底发生了什么。实际使用其实并不用这样做的。

使用CPU进行推理的具体代码看下面吧,依赖项在引入部分已经写清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
from fastapi import FastAPI, Depends, Request
from pydantic import BaseModel
from transformers import AutoTokenizer
from scipy.special import softmax
import json
import numpy as np
import onnxruntime

MODEL_DIR = "deberta-v3-large-absa-v1.1_onnx"

app = FastAPI()

def create_onnxruntime_infersess(model_file: str):
print(f"Infering using {onnxruntime.get_available_providers()}")
return onnxruntime.InferenceSession(model_file)

def create_tokenizer(model_dir: str):
return AutoTokenizer.from_pretrained(model_dir)

def create_id2label_dict(model_config: str):
with open(model_config, "r") as f:
return {int(k): v for k, v in json.load(f)["id2label"].items()}

ort_infersess = create_onnxruntime_infersess(f"{MODEL_DIR}/model.onnx")
bert_tokenizer = create_tokenizer(MODEL_DIR)
id2label_dict = create_id2label_dict(f"{MODEL_DIR}/config.json")

def get_inference_session():
return ort_infersess

def get_tokenizer():
return bert_tokenizer

def get_id2label_dict():
return id2label_dict

def predict(sentence: str, aspect: str):
infsess = get_inference_session()
tokenizer = get_tokenizer()
id2label = get_id2label_dict()

inputs = tokenizer(sentence, aspect, return_tensors="np")
outputs = infsess.run(
None,
{
"input_ids": inputs["input_ids"].astype(dtype=np.int64),
"attention_mask": inputs["attention_mask"].astype(dtype=np.int64),
},
)
pred_probs = softmax(outputs).flatten()
return [(id2label[arg], pred_probs[arg]) for arg in np.flip(pred_probs.argsort())]

def get_predict_handler(request: Request):
return predict

class SentimentAnalysisRequest(BaseModel):
sentence: str
aspect: str

@app.post("/analysis")
async def post_analysis(
request: SentimentAnalysisRequest, predictor=Depends(get_predict_handler)
):
sentence = request.sentence
aspect = request.aspect

result_list = predictor(sentence, aspect)
return {
"aspect": aspect,
"mostlike_label": result_list[0][0],
"mostlike_probability": f"{result_list[0][1]:.6f}",
"full_result": [{"label": label, "prob": f"{prob:.6f}"} for label, prob in result_list],
}

这将创建一个WebAPI,监听POST请求,请求主体是一个JSON格式的输入,包含sentenceaspect两个字段,前者是文本内容,后者就是希望获取态度的Aspect/Entity。

TODO

  • []试着在预训练模型上进行fine-tune.
  • []试着将推理过程运行在GPU上进行加速