核心内容摘要
OneAPI效果展示:Moonshot-K1+Qwen3+DeepSeek-R1三模型长上下文对比
原文towardsdatascience.com/memory-efficient-embeddings-d637cba7f006https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/ecd39944686ee1c2c55a328878b1592c.png照片由 Kostiantyn Vierkieiev 在 Unsplash 上提供在处理分类数据时初学者往往会求助于one-hot 编码。
这通常是可以的但如果你处理的是成千上万甚至数百万个类别这种方法就变得不可行。
这有以下原因维度增加对于每个类别你都会得到一个额外的特征。
这可能导致维度诅咒。
数据变得更加稀疏模型可能会遭受计算复杂性的增加和泛化性能的下降。
语义丢失one-hot 编码将每个类别视为一个独立特征忽略了类别之间任何潜在的语义关系。
我们失去了原始分类变量中存在的有意义的关系。
这些问题出现在自然语言处理我们有一堆单词或推荐系统我们有一堆客户和/或文章的领域并且可以通过嵌入的帮助来解决。
然而如果你有很多这样的嵌入你的模型对内存的需求可能会激增到几个吉字节。
在这篇文章中我想向你展示几种减少这种内存占用痕迹的方法。
其中一种方法来自一篇有趣的论文使用互补分区进行内存高效推荐系统的组合嵌入 by Shi 等人。
我们还将进行一些实验看看这些方法在评分预测任务中的表现如何。
嵌入简而言之我们想要的是长度为d的短而密集的向量而不是长而稀疏的向量——我们的嵌入。
嵌入维度d是一个我们可以自由选择的超参数。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/c4903e4e869cbe1ffb425949125d51c
pnghttps://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/fc8d6851495ec21c3857bdbbaf1a0f
png左one-hot 编码右嵌入。
图片由作者提供。
嵌入是短且应捕捉类别某些意义的实值向量。
这些实数是在训练过程中学习的。
对于 TensorFlow 中的具体实现请参阅我的另一篇文章基于嵌入的推荐系统简介模型大小嵌入非常棒一旦你使用了它们你就再也不想回去了。
然而如果你有很多类别你必须为每个类别存储一串实数。
你可以想象一个包含 6 个类别、维度为 4 的小嵌入表如下所示https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/38708a5ef1ee7d888fea4ee18e5b886c.png图由作者提供。
通常情况下对于N个类别和维度d该表的大小为N⋅d这也是内存需求来源的地方。
这个表的大小很容易失控在我的工作中我构建了一个基于嵌入的推荐系统为超过一百万的客户提供服务。
推荐系统的大小通常是700 MB这使得在某些地方部署模型变得困难例如 BigQuery。
这里你可以读到模型的最大大小限制为 450 MB。
所以让我们看看我们如何大约将这个模型的大小减半简单技巧以减少嵌入表大小有几种方法可以使这个表变小但通常情况下会有权衡减少表大小会降低你的模型性能。
尽管如此我们仍然可以尝试在内存和任务性能之间找到最佳平衡点——可能是一个 50 MB 的模型准确率达到 91%比一个 10 GB 的模型准确率达到 93%要好。
让我们看看我们如何实现这一点。
降低维度减少内存的最简单方法可能是减少维度d。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/afbb79a6e89cdeeef964a3af7203d
png图由作者提供。
这可能效果很好但一旦你的维度变得太低你的模型可能就不再足够表达了。
想象一下将维度降低到d 1例如。
如果你处理的是数百万个类别你可能会丢失很多信息。
尽管如此有些人喜欢以目标编码的形式进行这种极端压缩。
哈希技巧另一种方法是通过将几个类别分组在一起来减少类别数量N。
你可以有系统地做例如将你认为相当相似的几个类别组合在一起。
这可能是一项大量工作因此人们经常使用哈希函数来找到随机的分组。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/b9ca0034e375397a9269075b35a399d
png图由作者提供。
在这两种情况下不同的类别可以获取相同的嵌入。
这通常会使学习好的嵌入变得更加困难因为你可能会混淆行为本质上不同的类别。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/3023808dc221c3ffe5f753da65f
png行数越少意味着一些类别必须共享相同的嵌入。
图由作者提供。
降低浮点精度我之前省略了一件事那就是浮点数本身的大小。
我们可以将我们的嵌入下采样到 float16甚至 float8。
表格大小保持不变但每个单元格的位数减少了。
如果你使用 float16这会给你 50% 的减少率如果你使用 float8则是 75%。
注意不建议直接将所有 float32 转换为 float16因为这可能会引起训练不稳定即你在训练时得到 NaN。
组合嵌入让我们转向 Shi 等人撰写的论文使用互补分区进行内存高效推荐系统的组合嵌入。
在他们的论文中他们描述了一种比哈希技巧更好的节省内存的方法。
在我们深入探讨它是如何工作的之前让我们简要回顾一下我们如何使用我们的正常嵌入。
让我们假设N个类别从 0 编号到N– 1。
如果我们想检索类别i的嵌入我们只需查找矩阵的第i行并将这个向量作为嵌入输出。
完成。
想法他们提出了他们方法的许多不同版本但让我们从他们所谓的商-余数技巧开始。
在这里他们创建了两个包含维度d的较小表格。
让我们假设一个表格的大小为M另一个为N / M。
为了一个数值示例让我们假设我们有N 100 个类别并设置M 20。
那么我们有两个嵌入表格一个大小为 20另一个大小为 5。
https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/8d417e6ede9be2a0907e60edc9175c
png图片由作者提供。
让我们再次假设我们的N个类别从 0 编号到N– 1并且我们想要获取类别i的嵌入。
想法是查找两个****组合嵌入——每个表格一个——并将这两个嵌入组装成一个最终的嵌入例如通过逐分量相加或相乘。
作者建议相乘它们。
因此我们使用两个更小的表格创建了一个嵌入。
总共我们只需要存储M ⋅ d (N/M)⋅ d (M N/M)⋅ d个数字这总是小于N ⋅ d。
他们的算法我们如何知道应该查找较小表格中的哪些嵌入呢嗯做一下带余数的除法假设我们需要某个类别编号为i的嵌入。
如果你还记得你的学生时代你就会知道我们可以找到两个数q和r使得i q ⋅ M r。
q被称为商而r是余数。
例如取i 77。
我们可以将 77 写成3⋅ 20 17这里的商 3和余数 17是我们在较小表格中查找组合嵌入的索引。
通常我们得到以下算法https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/7fdfa2b4f60cb06bebff2355ac59524c.png从他们的论文中。
m M, D d, |S| N。
中间带点的圆圈表示向量的逐元素乘法也称为 Hadamard 积。
那么j 57 呢没问题2⋅ 20 17。
在这里我们可以看到类别i和j如何共享相同的余数嵌入17这将类别i 77 和j 57 相关联。
然而它们的商嵌入不同因此最终的嵌入也不同。
减少增益你可以通过i // M和i % M轻松地得到商和余数。
我喜欢这个想法因为它简单易懂且易于实现。
如果你想通过这种方法最小化内存使用你必须最小化函数f(M) M N/M在M上的值。
如果我们忽略M是一个整数的事实我们可以对f求导得到f‘(M) 1 –N/ _M_²如果我们解f‘(M) 0我们得到M √N.在这种情况下两个表大约具有相同的长度。
这意味着我们只需要存储大约 √_N ⋅ d 而不是旧的N ⋅ d值这是一个巨大的提升。
如果你想再次使用这些数字我们可以将 100⋅ d值降低到2 ⋅√100 ⋅ d 20⋅ d值即减少了 80%与d无关。
如果你认为这已经很多了那么就插入更大的N值这在使用嵌入时通常是情况。
对于N 10,000我们已经有了 98% 的巨大减少但请记住这种压缩级别可能会显著降低性能。
使用内存最优的均匀分割并不总是最佳选择。
从模型性能的角度来看不均匀的分割M≠ √N可能会更好。
***推广你可以轻松地推广这个想法。
对于一个给定的索引i只需将其以某种方式分成两个较小的数字。
为什么是两个你也可以分成更多——比如说k个数字并应用相同的逻辑。
一种方法是利用中国剩余定理这听起来比实际情况要复杂。
基本上你选择一些数字 _M_₁, _M_₂, …,Mₙ称为模数使得它们之间没有公共因子它们的乘积大于N。
这些是技术要求以确保两个不同的类别i和j获得不同的组合嵌入就像在商-余数的情况下一样。
然后你只需计算i % M_1,i % M_2, …,i % M_n这样你就有n个索引可以在n个大小为 _M_₁, _M_₂, …,Mₙ的更小表中查找。
内存需求因此简单为 (_M_₁ _M_₂ … Mₙ)⋅ d如果模数都大约是ⁿ√N 的大小这可以低至n⋅ ⁿ√N ⋅ d*。
作为一个数值示例取N 100n 5。
在这种情况下减少大约为 87%。
对于N 10,000 和n 5我们得到大约
9
7% 的内存需求减少。
再次提醒如果这还不够的话你可能需要选择不同的数字这会降低减少量但会提高模型的性能。
TensorFlow 中的实现在我们将此逻辑实现于 TensorFlow 之前让我们确保大家都处于同一认知水平。
让我们回顾一下如何在 TensorFlow 中使用嵌入层importtensorflowastf N100d64embedding_layertf.keras.layers.Embedding(N,d)Xtf.constant([1,2,3])print(Output shape:,embedding_layer(X).shape)print(Embedding table shape:,embedding_layer.get_weights()[0].shape)# Output:# Output shape: (3,
# Embedding table shape: (100,
商余层到目前为止一切都很简单。
现在让我们实现商余技巧。
以下代码应该对你来说是有意义的。
X是一个包含大量整数的张量一个数组。
在call方法中我们计算商和余数并从组合表quotient_embedding和remainder_embedding中查找相应的值。
classDivisionCompositionEmbedding(tf.keras.layers.Layer):def__init__(self,input_dim,output_dim,divisor,**embedding_layer_kwargs):super().__init__()self.input_diminput_dim self.output_dimoutput_dim self.divisordivisor self.quotient_embeddingtf.keras.layers.Embedding(input_dim//divisor,output_dim,**embedding_layer_kwargs)self.remainder_embeddingtf.keras.layers.Embedding(divisor,output_dim,**embedding_layer_kwargs)defcall(self,X):quotient_embeddingself.quotient_embedding(X//self.divisor)remainder_embeddingself.remainder_embedding(X%self.divisor)returnquotient_embedding*remainder_embedding你现在可以像使用其他任何层一样使用这个层。
N100d64M20embedding_layerDivisionCompositionEmbedding(N,d,M)Xtf.constant([1,2,3])print(Output shape:,embedding_layer(X).shape)print(First embedding table shape:,embedding_layer.get_weights()[0].shape)print(Second embedding table shape:,embedding_layer.get_weights()[1].shape)# Ouput:# Output shape: (3,
# First embedding table shape: (5,
# Second embedding table shape: (20,
中国剩余层这个也很简单。
只需看classChineseRemainderCompositionEmbedding(tf.keras.layers.Layer):def__init__(self,input_dim,output_dim,moduli,**embedding_layer_kwargs):super().__init__()self.input_diminput_dim self.output_dimoutput_dim self.modulimoduli self.embeddings[tf.keras.layers.Embedding(modulus,output_dim,**embedding_layer_kwargs)formodulusinmoduli]self.multiplytf.keras.layers.Multiply()defcall(self,X):embeddings[embedding(X%modulus)forembedding,modulusinzip(self.embeddings,self.moduli)]returnself.multiply(embeddings)让我们再次检查
使用方法N100d64Ms(7,11,
embedding_layerChineseRemainderCompositionEmbedding(N,d,Ms)Xtf.constant([1,2,3])print(Output shape:,embedding_layer(X).shape)print(First embedding table shape:,embedding_layer.get_weights()[0].shape)print(Second embedding table shape:,embedding_layer.get_weights()[1].shape)print(Third embedding table shape:,embedding_layer.get_weights()[2].shape)# Output:# Output shape: (3,
# First embedding table shape: (7,
# Second embedding table shape: (11,
# Third embedding table shape: (13,
性能结果作者使用了 Criteo Ad Kaggle 竞赛数据集来测试他们的点击率预测模型。
它有 13 个密集特征和 26 个分类特征。
他们使用了两种不同的神经网络架构来测试他们的方法DCM 和 DLRM。
以下是他们的一个结果https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/2349256773c94f7b54c706894deeb96c.png图 4来自他们的论文在 5 次试验中训练 DCN左和 Facebook DLRM右网络时验证损失与迭代次数的关系。
均值和标准差都被绘制出来。
“Full Table” 对应于使用完整嵌入表不使用哈希的基线“Hash Trick” 指的是哈希技巧“Q-R Trick” 指的是商余技巧带有逐元素乘法。
请注意哈希技巧和商余技巧导致模型大小大约减少 4 倍。
你可以看到哈希技巧和商余技巧产生了更小的模型但商余技巧的压缩方法似乎更好。
对于这个特定的数据集和这些模型哈希技巧的嵌入比商余技巧的嵌入对模型性能的损害更大。
他们在论文中有更多的图表确保查看不幸的是作者忘记比较他们新的嵌入层与简单的维度或精度降低方法。
如果只是将d减少 80%例如将会很有趣看到会发生什么。
我的实验我还通过构建一个简单的推荐器来对MovieLens 20M 数据集进行了实验。
这个数据集充满了用户以 1 到 5 颗星的形式对电影的评分。
我构建了一个回归模型嵌入了一个用户嵌入了一个电影连接这些嵌入使用线性层输出一个单一的数字。
这里是代码importtensorflowastfclassModel(tf.keras.Model):def__init__(self,user_model,movie_model):super().__init__()self.user_modeluser_model self.movie_modelmovie_model self.concatenatetf.keras.layers.Concatenate()self.lineartf.keras.layers.Dense(1,activationsigmoid)defcall(self,X):concatself.concatenate([self.user_model(X[:,0]),self.movie_model(X[:,1])])return
5*self.linear(concat)
5注意你可以将嵌入层传递给模型这样我就可以尝试一个完整的正常嵌入层也可以尝试哈希、商余和中国剩余技巧。
然后你可以使用polars这样加载数据# !pip install polarsimportpolarsaspl TRAIN_SET_SIZE19_000_000EMBEDDING_DIM8BATCH_SIZE1024ratings(pl.scan_csv(ml-20m/ratings.csv,).with_columns(pl.col(userId).rank(methoddense).alias(userId_encoded),pl.col(movieId).rank(methoddense).alias(movieId_encoded)).collect().sample(fraction
0,shuffleTrue,seed
)trainratings.head(TRAIN_SET_SIZE)evaluationratings.tail(-TRAIN_SET_SIZE)X_traintrain.select([userId_encoded,movieId_encoded]).cast(pl.Int
.to_numpy()y_traintrain.select([rating]).to_numpy()X_evaluationevaluation.select([userId_encoded,movieId_encoded]).cast(pl.Int
.to_numpy()y_evaluationevaluation.select([rating]).to_numpy()train_dstf.data.Dataset.from_tensor_slices((X_train,y_train)).batch(BATCH_SIZE).cache().prefetch(tf.data.AUTOTUNE)evaluation_dstf.data.Dataset.from_tensor_slices((X_evaluation,y_evaluation)).batch(BATCH_SIZE).cache().prefetch(tf.data.AUTOTUNE)注意我使用了相当小的嵌入维度 8因此进一步降低性能变得困难。
我们仍然可以将它降低到 1 维这会产生
8
5%的最大内存减少率。
我创建了一个小的训练函数deftrain(user_model,movie_model):modelModel(user_model,movie_model)model.compile(lossmse,optimizeradam)model.fit(train_ds,epochs3,validation_dataevaluation_ds,callbacks[tf.keras.callbacks.EarlyStopping()])print(model.summary())returnmodel我的结果https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/b9e09201d49110d4f78e0d55be2a2b4b.png在偶数情况下我选择了 M *N
5即最大压缩。
在奇数情况下我使用了 M N
8。
对于中国剩余我使用了大约 200 的 3 个模数。
图片由作者提供。
我可以复制出作者提出的方法确实比简单的哈希技巧要好。
但最大的惊喜是即使将维度减少到 1也能得到与原始模型相当的性能模型。
免费地实现了 87%的性能提升在模型性能和代码更改方面。
哇。
我并不是说简单的维度降低方法也会对作者有效但看到与他们的方法相比会发生什么将会很有趣。
我将发送消息并更新文章以便在收到答案时进行更新。
**注意**我也尝试使用 float16 代替 float32但遇到了上述数值问题训练无法完成。
结论与讨论在这篇文章中我们看到了嵌入是编码分类数据的巧妙工具。
然而这些嵌入可能导致难以部署的大型模型因此了解减少嵌入表大小的措施是很好的。
首先我们讨论了一些简单的方法例如减少表格的宽度减少d减少表格的高度减少N哈希技巧减少单元格的内存更少的浮点精度然后我通过 Shi 等人介绍了一个新的想法。
我们不再使用一个大的表格而是使用几个小的表格并将它们拼接起来以得到我们期望的结果。
这与每个类别都有一个嵌入并且随机组合类别以赋予它们相同的嵌入哈希在他们的方法中一个类别会得到几个组合嵌入然后可以相乘例如以得到最终的嵌入。
每个组合嵌入都被多个类别使用这与哈希技巧类似。
然而作者创建的方法确保两个不同类别的最终嵌入不会相同这与我们在完整哈希表中的情况类似。
这是一个你可以拥有的好方法但我的实验表明你永远不应该忘记简单的解决方案。
我希望你在今天学到了一些新的、有趣的和有价值的东西。
感谢阅读如果您有任何问题请通过LinkedIn联系我如果你想更深入地了解算法的世界试试我的新书《All About Algorithms》我仍在寻找作家All About Algorithms