paint-brush
产品驱动的机器学习文本分类案例研究经过@bemorelavender
29,341 讀數
29,341 讀數

产品驱动的机器学习文本分类案例研究

经过 Maria K17m2024/03/12
Read on Terminal Reader

太長; 讀書

这是一个产品驱动的机器学习案例研究:我们假设我们有一个需要改进的实际产品。我们将探索数据集并尝试不同的模型,例如逻辑回归、循环神经网络和变压器,看看它们的准确性如何,它们将如何改进产品,它们的工作速度有多快,以及它们是否易于调试并扩大规模。
featured image - 产品驱动的机器学习文本分类案例研究
Maria K HackerNoon profile picture


我们将假装我们有一个需要改进的实际产品。我们将探索数据集并尝试不同的模型,例如逻辑回归、循环神经网络和变压器,看看它们的准确性如何,它们将如何改进产品,它们的工作速度有多快,以及它们是否易于调试并扩大规模。


您可以在GitHub上阅读完整的案例研究代码,并在Jupyter Notebook Viewer中查看带有交互式图表的分析笔记本。


兴奋的?让我们开始吧!

任务设定

想象一下我们拥有一个电子商务网站。在此网站上,卖家可以上传他们想要出售的商品的描述。他们还必须手动选择项目的类别,这可能会减慢他们的速度。


我们的任务是根据项目描述自动选择类别。然而,错误的自动化选择比没有自动化更糟糕,因为错误可能会被忽视,从而可能导致销售损失。因此,如果我们不确定,我们可能会选择不设置自动标签。


对于本案例研究,我们将使用Zenodo电子商务文本数据集,包含项目描述和类别。


是好是坏?如何选择最佳型号

我们将在下面考虑多种模型架构,在开始之前决定如何选择最佳选项始终是一个很好的做法。这种模式将如何影响我们的产品? ……我们的基础设施?


显然,我们将有一个技术质量指标来离线比较各种模型。在本例中,我们有一个多类分类任务,因此让我们使用平衡的准确度分数,它可以很好地处理不平衡的标签。


当然,测试候选人的典型最后阶段是 AB 测试 - 在线阶段,它可以更好地了解客户如何受到变更的影响。通常,AB 测试比离线测试更耗时,因此只有离线阶段的最佳候选者才会接受测试。这是一个案例研究,我们没有实际用户,因此我们不会讨论AB 测试。


在让候选人进行 AB 测试之前,我们还应该考虑什么?在离线阶段我们可以考虑什么来节省一些在线测试时间并确保我们真正测试的是最佳解决方案?


将技术指标转化为影响力指标

平衡的准确性很好,但这个分数并不能回答“模型到底如何影响产品?”的问题。为了找到更多以产品为导向的分数,我们必须了解我们将如何使用该模型。


在我们的设置中,犯错误比不回答更糟糕,因为卖家必须注意到错误并手动更改类别。一个未被注意到的错误会减少销量并使卖家的用户体验变得更糟,我们面临失去客户的风险。


为了避免这种情况,我们将为模型分数选择阈值,这样我们只允许自己犯 1% 的错误。然后可以按如下方式设置面向产品的指标:


如果我们的容错率只有 1%,那么我们可以自动分类的项目百分比是多少?


在选择最佳模型时,我们将在下面将其称为Automatic categorisation percentage在此查找完整的阈值选择代码。


推理时间

模型处理一个请求需要多长时间?


这将大致允许我们比较如果选择一种模型而不是另一种模型,我们需要为服务处理任务负载维护多少资源。


可扩展性

当我们的产品要增长时,使用给定的架构来管理增长有多容易?


我们所说的增长可能是指:

  • 品类更多,品类粒度更高
  • 更长的描述
  • 更大的数据集
  • ETC

我们是否必须重新考虑模型选择来应对增长,或者简单的重新训练就足够了?


可解释性

在训练期间和部署之后调试模型的错误有多容易?


型号尺寸

如果出现以下情况,模型尺寸很重要:

  • 我们希望我们的模型在客户端进行评估
  • 它太大了以至于无法放入 RAM


稍后我们会看到上面的两项都不相关,但仍然值得简要考虑。

数据集探索与清理

我们正在做什么?让我们看一下数据,看看是否需要清理!


该数据集包含 2 列:项目描述和类别,总共 50.5k 行。

 file_name = "ecommerceDataset.csv" data = pd.read_csv(file_name, header=None) data.columns = ["category", "description"] print("Rows, cols:", data.shape) # >>> Rows, cols: (50425, 2)


每个项目都分配有 4 个可用类别中的 1 个: HouseholdBooksElectronicsClothing & Accessories 。以下是每个类别 1 个项目描述示例:


  • 家用SPK 家居装饰粘土手工壁挂脸(多色,高 35x 宽 12 厘米)用这款手工制作的赤土印第安面罩壁挂让您的家更加美丽,以前您在市场上找不到这款手工制作的东西。您可以将其添加到您的客厅/入口大厅。


  • 书籍BEGF101/FEG1-英语基础课程-1 (Neeraj Publications 2018 版) BEGF101/FEG1-英语基础课程-1


  • 服装和配饰Broadstar 女式牛仔背带裤 穿着 Broadstar 背带裤获得全通行证。这些工装裤采用牛仔布制成,让您倍感舒适。搭配白色或黑色上衣,打造休闲造型。


  • Electronics Caprigo 重型 - 2 英尺高级投影仪天花板安装支架(可调节 - 白色 - 承重 15 公斤)


缺失值

数据集中只有一个空值,我们将删除它。

 print(data.info()) # <class 'pandas.core.frame.DataFrame'> # RangeIndex: 50425 entries, 0 to 50424 # Data columns (total 2 columns): # # Column Non-Null Count Dtype # --- ------ -------------- ----- # 0 category 50425 non-null object # 1 description 50424 non-null object # dtypes: object(2) # memory usage: 788.0+ KB data.dropna(inplace=True)


重复项

然而,有很多重复的描述。幸运的是,所有重复项都属于一个类别,因此我们可以安全地删除它们。

 repeated_messages = data \ .groupby("description", as_index=False) \ .agg( n_repeats=("category", "count"), n_unique_categories=("category", lambda x: len(np.unique(x))) ) repeated_messages = repeated_messages[repeated_messages["n_repeats"] > 1] print(f"Count of repeated messages (unique): {repeated_messages.shape[0]}") print(f"Total number: {repeated_messages['n_repeats'].sum()} out of {data.shape[0]}") # >>> Count of repeated messages (unique): 13979 # >>> Total number: 36601 out of 50424


删除重复项后,我们剩下原始数据集的 55%。数据集非常平衡。

 data.drop_duplicates(inplace=True) print(f"New dataset size: {data.shape}") print(data["category"].value_counts()) # New dataset size: (27802, 2) # Household 10564 # Books 6256 # Clothing & Accessories 5674 # Electronics 5308 # Name: category, dtype: int64


描述语言

请注意,根据数据集描述,

该数据集是从印度电子商务平台上抓取的。


描述不一定是用英语写的。其中一些是使用非 ASCII 符号以印地语或其他语言编写的,或者音译为拉丁字母,或者使用混合语言。 Books类别的示例:


  • यू जी सी – नेट जूनियर रिसर्च फैलोशिप एवं सहायक प्रोफेसर योग्यता …
  • Prarambhik Bhartiy Itihas
  • History of NORTH INDIA/வட இந்திய வரலாறு/ …


为了评估描述中是否存在非英语单词,我们计算 2 个分数:


  • ASCII 分数:描述中非 ASCII 符号的百分比
  • 有效英语单词得分:如果我们只考虑拉丁字母,描述中有效英语单词的百分比是多少?假设有效的英语单词是在英语语料库上训练的Word2Vec-300中存在的单词。


使用 ASCII 分数,我们了解到只有 2.3% 的描述包含超过 1% 的非 ASCII 符号。

 def get_ascii_score(description): total_sym_cnt = 0 ascii_sym_cnt = 0 for sym in description: total_sym_cnt += 1 if sym.isascii(): ascii_sym_cnt += 1 return ascii_sym_cnt / total_sym_cnt data["ascii_score"] = data["description"].apply(get_ascii_score) data[data["ascii_score"] < 0.99].shape[0] / data.shape[0] # >>> 0.023


有效英语单词得分显示,只有 1.5% 的描述中 ASCII 单词中有效英语单词的比例低于 70%。

 w2v_eng = gensim.models.KeyedVectors.load_word2vec_format(w2v_path, binary=True) def get_valid_eng_score(description): description = re.sub("[^az \t]+", " ", description.lower()) total_word_cnt = 0 eng_word_cnt = 0 for word in description.split(): total_word_cnt += 1 if word.lower() in w2v_eng: eng_word_cnt += 1 return eng_word_cnt / total_word_cnt data["eng_score"] = data["description"].apply(get_valid_eng_score) data[data["eng_score"] < 0.7].shape[0] / data.shape[0] # >>> 0.015


因此,大多数描述(约 96%)都是英文或大部分是英文。我们可以删除所有其他描述,但让我们保持原样,然后看看每个模型如何处理它们。

造型

让我们将数据集分为 3 组:

  • 训练 70% - 用于训练模型(19k 消息)

  • 测试 15% - 用于参数和阈值选择(4.1k 消息)

  • Eval 15% - 用于选择最终模型(4.1k 消息)


 from sklearn.model_selection import train_test_split data_train, data_test = train_test_split(data, test_size=0.3) data_test, data_eval = train_test_split(data_test, test_size=0.5) data_train.shape, data_test.shape, data_eval.shape # >>> ((19461, 3), (4170, 3), (4171, 3))


基线模型:词袋+逻辑回归

一开始做一些简单而琐碎的事情有助于获得良好的基线。作为基线,让我们根据训练数据集创建一个词袋结构。


我们还将字典大小限制为 100 个单词。

 count_vectorizer = CountVectorizer(max_features=100, stop_words="english") x_train_baseline = count_vectorizer.fit_transform(data_train["description"]) y_train_baseline = data_train["category"] x_test_baseline = count_vectorizer.transform(data_test["description"]) y_test_baseline = data_test["category"] x_train_baseline = x_train_baseline.toarray() x_test_baseline = x_test_baseline.toarray()


我计划使用逻辑回归作为模型,因此我需要在训练之前对计数器特征进行标准化。

 ss = StandardScaler() x_train_baseline = ss.fit_transform(x_train_baseline) x_test_baseline = ss.transform(x_test_baseline) lr = LogisticRegression() lr.fit(x_train_baseline, y_train_baseline) balanced_accuracy_score(y_test_baseline, lr.predict(x_test_baseline)) # >>> 0.752


多类逻辑回归显示平衡准确度为 75.2%。这是一个很好的基线!


虽然整体分类质量不是很好,但该模型仍然可以给我们一些启示。让我们看一下根据预测标签数量进行归一化的混淆矩阵。 X 轴表示预测类别,Y 轴表示真实类别。查看每一列,我们可以看到预测某个类别时真实类别的分布。


基线解决方案的混淆矩阵。


例如, Electronics经常与Household混淆。但即使是这个简单的模型也可以非常精确地捕捉Clothing & Accessories


以下是预测Clothing & Accessories类别时的特征重要性:

“服装和配饰”标签基线解决方案的特征重要性


支持和反对Clothing & Accessories类别的前 6 个最有贡献的词:

 women 1.49 book -2.03 men 0.93 table -1.47 cotton 0.92 author -1.11 wear 0.69 books -1.10 fit 0.40 led -0.90 stainless 0.36 cable -0.85


RNN

现在让我们考虑更高级的模型,专门设计用于序列 -循环神经网络GRULSTM是常见的高级层,用于对抗简单 RNN 中出现的梯度爆炸。


我们将使用pytorch库来标记描述,并构建和训练模型。


首先,我们需要将文本转换为数字:

  1. 将描述拆分为文字
  2. 根据训练数据集为语料库中的每个单词分配索引
  3. 为未知单词和填充保留特殊索引
  4. 将训练和测试数据集中的每个描述转换为索引向量。


我们通过简单地对训练数据集进行标记而获得的词汇量很大 - 几乎 90k 个单词。我们拥有的单词越多,模型必须学习的嵌入空间就越大。为了简化训练,让我们从中删除最稀有的单词,只保留那些出现在至少 3% 的描述中的单词。这会将词汇量缩减至 340 个单词。

在此处找到完整的CorpusDictionary实现)


 corpus_dict = util.CorpusDictionary(data_train["description"]) corpus_dict.truncate_dictionary(min_frequency=0.03) data_train["vector"] = corpus_dict.transform(data_train["description"]) data_test["vector"] = corpus_dict.transform(data_test["description"]) print(data_train["vector"].head()) # 28453 [1, 1, 1, 1, 12, 1, 2, 1, 6, 1, 1, 1, 1, 1, 6,... # 48884 [1, 1, 13, 34, 3, 1, 1, 38, 12, 21, 2, 1, 37, ... # 36550 [1, 60, 61, 1, 62, 60, 61, 1, 1, 1, 1, 10, 1, ... # 34999 [1, 34, 1, 1, 75, 60, 61, 1, 1, 72, 1, 1, 67, ... # 19183 [1, 83, 1, 1, 87, 1, 1, 1, 12, 21, 42, 1, 2, 1... # Name: vector, dtype: object


接下来我们需要决定的是我们将作为 RNN 输入的向量的公共长度。我们不想使用完整的向量,因为最长的描述包含 9.4k 个标记。


然而,训练数据集中 95% 的描述不超过 352 个标记 - 这是一个适合修剪的长度。较短的描述会发生什么?


它们将用填充索引填充到公共长度。

 print(max(data_train["vector"].apply(len))) # >>> 9388 print(int(np.quantile(data_train["vector"].apply(len), q=0.95))) # >>> 352


接下来 - 我们需要将目标类别转换为 0-1 向量来计算损失并在每个训练步骤上执行反向传播。

 def get_target(label, total_labels=4): target = [0] * total_labels target[label_2_idx.get(label)] = 1 return target data_train["target"] = data_train["category"].apply(get_target) data_test["target"] = data_test["category"].apply(get_target)


现在我们准备创建自定义pytorch数据集和数据加载器以输入模型。 在此处查找完整的PaddedTextVectorDataset实现。

 ds_train = util.PaddedTextVectorDataset( data_train["description"], data_train["target"], corpus_dict, max_vector_len=352, ) ds_test = util.PaddedTextVectorDataset( data_test["description"], data_test["target"], corpus_dict, max_vector_len=352, ) train_dl = DataLoader(ds_train, batch_size=512, shuffle=True) test_dl = DataLoader(ds_test, batch_size=512, shuffle=False)


最后,让我们建立一个模型。


最小的架构是:

  • 嵌入层
  • RNN层
  • 线性层
  • 激活层


从较小的参数值(嵌入向量的大小、RNN 中隐藏层的大小、RNN 层数)开始并且不进行正则化,我们可以逐渐使模型变得更加复杂,直到表现出强烈的过拟合迹象,然后进行平衡正则化(RNN 层和最后一个线性层之前的丢失)。


 class GRU(nn.Module): def __init__(self, vocab_size, embedding_dim, n_hidden, n_out): super().__init__() self.vocab_size = vocab_size self.embedding_dim = embedding_dim self.n_hidden = n_hidden self.n_out = n_out self.emb = nn.Embedding(self.vocab_size, self.embedding_dim) self.gru = nn.GRU(self.embedding_dim, self.n_hidden) self.dropout = nn.Dropout(0.3) self.out = nn.Linear(self.n_hidden, self.n_out) def forward(self, sequence, lengths): batch_size = sequence.size(1) self.hidden = self._init_hidden(batch_size) embs = self.emb(sequence) embs = pack_padded_sequence(embs, lengths, enforce_sorted=True) gru_out, self.hidden = self.gru(embs, self.hidden) gru_out, lengths = pad_packed_sequence(gru_out) dropout = self.dropout(self.hidden[-1]) output = self.out(dropout) return F.log_softmax(output, dim=-1) def _init_hidden(self, batch_size): return Variable(torch.zeros((1, batch_size, self.n_hidden)))


我们将使用Adam优化器和cross_entropy作为损失函数。


 vocab_size = len(corpus_dict.word_to_idx) emb_dim = 4 n_hidden = 15 n_out = len(label_2_idx) model = GRU(vocab_size, emb_dim, n_hidden, n_out) opt = optim.Adam(model.parameters(), 1e-2) util.fit( model=model, train_dl=train_dl, test_dl=test_dl, loss_fn=F.cross_entropy, opt=opt, epochs=35 ) # >>> Train loss: 0.3783 # >>> Val loss: 0.4730 

每个时期的训练和测试损失,RNN 模型

该模型在评估数据集上显示出 84.3% 的平衡准确度。哇,多么大的进步啊!


引入预训练嵌入

从头开始训练 RNN 模型的主要缺点是它必须学习单词本身的含义 - 这是嵌入层的工作。预训练的word2vec模型可用作现成的嵌入层,这减少了参数数量并为标记添加了更多含义。让我们使用pytorch中可用的word2vec模型之一 - glove, dim=300


我们只需要对数据集的创建进行微小的更改 - 我们现在想要为每个描述和模型架构创建一个glove预定义索引的向量。

 ds_emb_train = util.PaddedTextVectorDataset( data_train["description"], data_train["target"], emb=glove, max_vector_len=max_len, ) ds_emb_test = util.PaddedTextVectorDataset( data_test["description"], data_test["target"], emb=glove, max_vector_len=max_len, ) dl_emb_train = DataLoader(ds_emb_train, batch_size=512, shuffle=True) dl_emb_test = DataLoader(ds_emb_test, batch_size=512, shuffle=False)
 import torchtext.vocab as vocab glove = vocab.GloVe(name='6B', dim=300) class LSTMPretrained(nn.Module): def __init__(self, n_hidden, n_out): super().__init__() self.emb = nn.Embedding.from_pretrained(glove.vectors) self.emb.requires_grad_ = False self.embedding_dim = 300 self.n_hidden = n_hidden self.n_out = n_out self.lstm = nn.LSTM(self.embedding_dim, self.n_hidden, num_layers=1) self.dropout = nn.Dropout(0.5) self.out = nn.Linear(self.n_hidden, self.n_out) def forward(self, sequence, lengths): batch_size = sequence.size(1) self.hidden = self.init_hidden(batch_size) embs = self.emb(sequence) embs = pack_padded_sequence(embs, lengths, enforce_sorted=True) lstm_out, (self.hidden, _) = self.lstm(embs) lstm_out, lengths = pad_packed_sequence(lstm_out) dropout = self.dropout(self.hidden[-1]) output = self.out(dropout) return F.log_softmax(output, dim=-1) def init_hidden(self, batch_size): return Variable(torch.zeros((1, batch_size, self.n_hidden)))


我们准备好训练了!

 n_hidden = 50 n_out = len(label_2_idx) emb_model = LSTMPretrained(n_hidden, n_out) opt = optim.Adam(emb_model.parameters(), 1e-2) util.fit(model=emb_model, train_dl=dl_emb_train, test_dl=dl_emb_test, loss_fn=F.cross_entropy, opt=opt, epochs=11) 

每个 epoch 的训练和测试损失,RNN 模型 + 预训练嵌入

现在我们在评估数据集上获得了 93.7% 的平衡准确度。哇!


伯特

用于处理序列的现代最先进的模型是变压器。然而,要从头开始训练变压器,我们需要大量的数据和计算资源。我们在这里可以尝试的是微调一个预先训练的模型来服务于我们的目的。为此,我们需要下载预训练的 BERT 模型并添加 dropout 和线性层以获得最终预测。建议训练 4 个 epoch 的调整模型。为了节省时间,我只额外训练了 2 个 epoch——我花了 40 分钟才做到这一点。


 from transformers import BertModel class BERTModel(nn.Module): def __init__(self, n_out=12): super(BERTModel, self).__init__() self.l1 = BertModel.from_pretrained('bert-base-uncased') self.l2 = nn.Dropout(0.3) self.l3 = nn.Linear(768, n_out) def forward(self, ids, mask, token_type_ids): output_1 = self.l1(ids, attention_mask = mask, token_type_ids = token_type_ids) output_2 = self.l2(output_1.pooler_output) output = self.l3(output_2) return output


 ds_train_bert = bert.get_dataset( list(data_train["description"]), list(data_train["target"]), max_vector_len=64 ) ds_test_bert = bert.get_dataset( list(data_test["description"]), list(data_test["target"]), max_vector_len=64 ) dl_train_bert = DataLoader(ds_train_bert, sampler=RandomSampler(ds_train_bert), batch_size=batch_size) dl_test_bert = DataLoader(ds_test_bert, sampler=SequentialSampler(ds_test_bert), batch_size=batch_size)


 b_model = bert.BERTModel(n_out=4) b_model.to(torch.device("cpu")) def loss_fn(outputs, targets): return torch.nn.BCEWithLogitsLoss()(outputs, targets) optimizer = optim.AdamW(b_model.parameters(), lr=2e-5, eps=1e-8) epochs = 2 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=0, num_training_steps=total_steps ) bert.fit(b_model, dl_train_bert, dl_test_bert, optimizer, scheduler, loss_fn, device, epochs=epochs) torch.save(b_model, "models/bert_fine_tuned")


训练日志:

 2024-02-29 19:38:13.383953 Epoch 1 / 2 Training... 2024-02-29 19:40:39.303002 step 40 / 305 done 2024-02-29 19:43:04.482043 step 80 / 305 done 2024-02-29 19:45:27.767488 step 120 / 305 done 2024-02-29 19:47:53.156420 step 160 / 305 done 2024-02-29 19:50:20.117272 step 200 / 305 done 2024-02-29 19:52:47.988203 step 240 / 305 done 2024-02-29 19:55:16.812437 step 280 / 305 done 2024-02-29 19:56:46.990367 Average training loss: 0.18 2024-02-29 19:56:46.990932 Validating... 2024-02-29 19:57:51.182859 Average validation loss: 0.10 2024-02-29 19:57:51.182948 Epoch 2 / 2 Training... 2024-02-29 20:00:25.110818 step 40 / 305 done 2024-02-29 20:02:56.240693 step 80 / 305 done 2024-02-29 20:05:25.647311 step 120 / 305 done 2024-02-29 20:07:53.668489 step 160 / 305 done 2024-02-29 20:10:33.936778 step 200 / 305 done 2024-02-29 20:13:03.217450 step 240 / 305 done 2024-02-29 20:15:28.384958 step 280 / 305 done 2024-02-29 20:16:57.004078 Average training loss: 0.08 2024-02-29 20:16:57.004657 Validating... 2024-02-29 20:18:01.546235 Average validation loss: 0.09


最后,经过微调的 BERT 模型在评估数据集上显示出高达 95.1% 的平衡准确度。


选择我们的获胜者

我们已经制定了一系列考虑因素,以做出最终的明智选择。

以下是显示可测量参数的图表:

模型的性能指标


尽管经过微调的 BERT 在质量上处于领先地位,但带有预训练嵌入层LSTM+EMB的 RNN 紧随其后,仅落后于自动类别分配的 3%。


另一方面,微调后的 BERT 的推理时间比LSTM+EMB长 14 倍。这将增加后端维护成本,这可能会超过微调BERT相对于LSTM+EMB带来的好处。


至于互操作性,我们的基线逻辑回归模型是迄今为止最具可解释性的,任何神经网络在这方面都输给了它。同时,基线可能是可扩展性最差的 - 添加类别将降低基线本已较低的质量。


尽管 BERT 看起来像超级明星,具有高精度,但我们最终还是选择了带有预训练嵌入层的 RNN。为什么?它非常准确,不太慢,而且当事情变大时也不会变得太复杂而难以处理。


希望您喜欢这个案例研究。您会选择哪种型号?为什么?