使用 Clojure MXNet 实现 CNN 文本分类:MR 情感分析数据集的完整实战指南
【免费下载链接】mxnetLightweight, Portable, Flexible Distributed/Mobile Deep Learning with Dynamic, Mutation-aware Dataflow Dep Scheduler; for Python, R, Julia, Scala, Go, Javascript and more项目地址: https://gitcode.com/gh_mirrors/mxnet1/mxnet
本篇技术指南以 Apache MXNet 仓库中contrib/clojure-package的cnn-text-classification示例为蓝本,完整讲解如何在 Clojure 生态中使用 MXNet 构建 Yoon Kim 提出的多滤波器卷积神经网络(CNN)进行电影评论情感二分类。你将掌握数据下载、预训练词向量(GloVe / fastText / word2vec)接入、REPL 与命令行两种训练方式、多设备(CPU/GPU)调度,以及底层数据预处理与网络结构实现细节,可直接复现并迁移到自己的文本分类任务。
示例概述:Clojure 里的卷积文本分类器
cnn-text-classification是 Clojure MXNet 包(contrib/clojure-package/README.md)提供的一个端到端文本分类示例,其网络结构直接参考了 Yoon Kim 的经典论文《Convolutional Neural Networks for Sentence Classification》。核心思路是:把一条句子表示为词向量矩阵,用多个不同宽度的卷积核(filter)并行扫描句子,捕捉 n-gram 级别的局部特征,再通过全局最大池化汇总,最后送入全连接层与 softmax 完成二分类。
该示例所在的完整目录结构如下:
contrib/clojure-package/examples/cnn-text-classification/ ├── project.clj # Leiningen 工程配置与依赖声明 ├── get_data.sh # 一键下载 MR 数据集与 GloVe 词向量 ├── get_fasttext_data.sh # 一键下载 fastText 预训练词向量 ├── src/cnn_text_classification/ │ ├── classifier.clj # 网络定义、训练入口 train-convnet │ └── data_helper.clj # 数据加载、清洗、填充与词向量接入 └── test/cnn_text_classification/ └── classifier_test.clj # 端到端训练冒烟测试工程依赖在 project.clj 中声明:Clojure 1.9.0,以及org.apache.mxnet.contrib.clojure/clojure-mxnet "1.6.0-SNAPSHOT"(Clojure 包基于 Scala 包通过 Java Interop 构建,核心仍为 MXNet C++ 引擎)。主入口为cnn-text-classification.classifier,可直接通过lein run启动。
第一步:准备 MR 情感分析数据集
示例使用的训练数据是 MR(Movie Review)极性数据集:rt-polarity.neg(负面评论)与rt-polarity.pos(正面评论)两个文本文件,每行一条影评。来自 yoonkim 的 CNN_sentence 项目(即上述论文作者公开的数据)。
原 README 要求将这两个文件放入mr-dataset-path指定的目录(即data/mr-data/)。更省事的做法是直接运行仓库自带的 get_data.sh,它会自动完成全部下载与解压:
mkdir -p data/mr-data wget <rt-polarity.neg 下载地址> wget <rt-polarity.pos 下载地址> mkdir -p data/glove wget <glove.6B.zip 下载地址> unzip *.zip脚本执行完成后,示例根目录下会生成:
data/mr-data/rt-polarity.neg data/mr-data/rt-polarity.pos data/glove/glove.6B.50d.txt # 以及 glove.6B.100d/200d/300d 等其他维度值得一提的是,classifier.clj在加载命名空间时就会自动检查data/目录是否存在,不存在则调用./get_data.sh拉取数据,因此首次运行时无需手动干预:
(def>(defn glove-file-path "Returns the file path to GloVe embedding of the input size" [embedding-size] (format "data/glove/glove.6B.%dd.txt" embedding-size))即 50 维对应data/glove/glove.6B.50d.txt,300 维对应data/glove/glove.6B.300d.txt,路径中维度由:embedding-size参数动态决定。
方式一:通过 REPL 交互式训练(子集)
进入项目目录启动 REPL 后,调用train-convnet即可开始训练。README 给出的子集训练命令为:
(train-convnet {:devs [(context/default-context)] :embedding-size 50 :batch-size 100 :test-size 100 :num-epoch 10 :max-examples 1000 :pretrained-embedding :glove})各参数含义如下表:
| 参数 | 取值示例 | 含义 |
|---|---|---|
:devs | [(context/default-context)] | 训练使用的设备上下文列表,默认即 CPU(0) |
:embedding-size | 50 | 词向量维度,必须与预训练文件维度一致 |
:batch-size | 100 | 每批训练的样本数 |
:test-size | 100 | 从打乱后的数据中划分出的测试样本数 |
:num-epoch | 10 | 训练轮数 |
:max-examples | 1000 | 正/负样本各取前 1000 条,用于控制时间与内存 |
:pretrained-embedding | :glove | 预训练词向量类型,可为:glove/:fasttext/:word2vec/nil |
注意:max-examples 1000表示正、负样本各加载 1000 条(共 2000 条),这是为了控制训练时间和内存占用而刻意设置的子集模式。
方式二:通过 lein run 命令行训练
如果不进 REPL,可以直接用命令行启动训练。CPU 环境下建议先调大 JVM 堆内存:
JVM_OPTS="-Xmx1g" lein run-main函数还支持通过命令行参数控制训练设备(见 classifier.clj 的-main实现),语法为lein run <设备类型> <设备数量>:
| 命令 | 效果 |
|---|---|
lein run :cpu 2 | 在 2 个 CPU 设备上训练 |
lein run :gpu 1 | 在 1 个 GPU 设备上训练 |
lein run :gpu 2 | 在 2 个 GPU 设备上训练 |
其设备上下文由context/cpu、context/gpu构造(见 context.clj,二者分别对应 MXNet 的Context("cpu", device-id)与Context("gpu", device-id)),devs会作为一个向量整体传给 Module,实现多设备数据并行:
(defn -main [& args] (let [[dev dev-num] args devs (if (= dev ":gpu") (mapv #(context/gpu %) (range (Integer/parseInt (or dev-num "1")))) (mapv #(context/cpu %) (range (Integer/parseInt (or dev-num "1")))))] (println "Running with context devices of" devs) (train-convnet {:devs devs :embedding-size 50 :batch-size 10 :test-size 100 :num-epoch 10 :max-examples 1000 :pretrained-embedding :glove})))默认-main使用子集参数(max-examples 1000、test-size 100、batch-size 10)训练 10 轮。
全量数据训练:lein uberjar 打包运行
要跑完 MR 数据集的全部样本,需要先修改classifier.clj中-main(或直接调用train-convnet)的参数,去掉:max-examples并将:test-size提到 1000:
(train-convnet {:embedding-size 50 :batch-size 100 :test-size 1000 :num-epoch 10 :pretrained-embedding :glove})然后打包成可执行 jar 并运行(JVM 堆设置为 1GB~2GB):
lein uberjar java -Xms1024m -Xmx2048m -jar target/cnn-text-classification-0.1.0-SNAPSHOT-standalone.jarMR 数据集全量约一万条样本,跑全量时内存和显存占用会明显上升,因此 README 与源码注释都强调:限制内存时保持max-examples 1000、test-size 100的配置即可。
使用 fastText 预训练词向量
fastText 的接入方式与 GloVe 几乎一致,因为其预训练词向量同样是文本格式(每行一个词 + 对应向量分量)。步骤为:
- 下载 fastText 官方发布的 "Simple English" 维基百科预训练词向量(文本格式
wiki.simple.vec); - 将其放入
data/fasttext/目录(即contrib/clojure-package/examples/cnn-text-classification/data/fasttext/); - 或者直接运行仓库脚本 get_fasttext_data.sh 一键下载到正确位置。
然后通过 REPL 在数据子集上训练(注意此处:embedding-size必须改为 300,因为 Simple English 向量维度为 300):
(train-convnet {:devs [(context/default-context)] :embedding-size 300 :batch-size 100 :test-size 100 :num-epoch 10 :max-examples 1000 :pretrained-embedding :fasttext})按上述参数,README 说明预期可达到约0.67的验证准确率。
在底层,data_helper.clj 的load-fasttext!会跳过wiki.simple.vec首行的元数据头(词数 + 维度),再逐行解析词向量:
(def remove-fasttext-metadata rest) ;; 丢弃首行 "单词数 维度" 元数据 (defn load-fasttext! [fasttext-file-path] (println "Loading the fastText pre-trained word embeddings from " fasttext-file-path) (->> (io/reader fasttext-file-path) line-seq remove-fasttext-metadata read-text-embedding-pairs (into {})))使用 word2vec 预训练词向量
word2vec 同样受支持,但使用的是 GoogleNews 的二进制格式向量GoogleNews-vectors-negative300.bin(gzip 压缩包)。步骤为:
- 下载该文件并解压;
- 将解压后的
.bin文件放到contrib/clojure-package/data目录下。
该路径由data_helper.clj中的常量指定:w2v-file-path "../../data/GoogleNews-vectors-negative300.bin"(相对于示例目录解析,最终指向contrib/clojure-package/data/)。
子集训练命令:
(train-convnet {:devs [(context/default-context)] :embedding-size 300 :batch-size 100 :test-size 100 :num-epoch 10 :max-examples 1000 :pretrained-embedding :word2vec})全量训练命令:
(train-convnet {:devs [(context/default-context)] :embedding-size 300 :batch-size 100 :test-size 1000 :num-epoch 10 :pretrained-embedding :word2vec})按上述全量参数,README 说明预期准确率约0.78。需要特别留意两点:
- 内存:加载 word2vec 词向量非常消耗内存且耗时较长,README 明确提示需用
JVM_OPTS="-Xmx8g" lein run启动; - 维度校验:
load-word2vec-model!在加载时会校验:embedding-size与二进制文件头部记录的维度是否一致,不一致会抛出ex-info("Mismatch in embedding size")。
word2vec 是二进制格式,data_helper.clj专门实现了r-string(逐字节读取直到空格或换行)与get-float/read-float(按小端序读取 float)来解析头部与前 300 维向量,load-w2v-vectors采用惰性序列(lazy-seq)流式读取,避免一次性把全部 300 万词向量载入内存:
(defn- load-w2v-vectors "Lazily loads the word2vec vectors ..." [dis embedding-size num-vectors] (if (= 0 num-vectors) (list) (let [word (r-string dis) vect (mapv (fn [_] (read-float dis)) (range embedding-size))] (cons [word vect] (lazy-seq (load-w2v-vectors dis embedding-size (dec num-vectors)))))))使用学习得到的词嵌入(不加载预训练向量)
最后一种方式与 Python 版 CNN 文本分类示例一致:不加载任何预训练向量,让模型在训练过程中直接学习词嵌入。只需将:pretrained-embedding设为nil(或省略该参数):
(train-convnet {:devs [(context/default-context)] :embedding-size 50 :batch-size 100 :test-size 100 :num-epoch 10 :max-examples 1000 :pretrained-embedding nil})此时网络符号图会在数据符号后插入一个embedding层("vocab_embed"),把每个词的词汇表索引映射为可训练的稠密向量:
(defn get-data-symbol [num-embed sentence-size batch-size vocab-size pretrained-embedding] (if pretrained-embedding (sym/variable "data") (as-> (sym/variable "data") data (sym/embedding "vocab_embed" {:data data :input-dim vocab-size :output-dim num-embed}) (sym/reshape {:data data :target-shape [batch-size 1 sentence-size num-embed]}))))对应地,两种模式下的数据张量形状也不同(shuffle-data中体现):
- 使用预训练向量:
[样本数 1 句长 向量维度](channel x y,channel 恒为 1); - 学习嵌入:
[样本数 1 句长](仅保存词索引,词向量由 embedding 层产出)。
源码剖析:多滤波器卷积网络架构
核心网络由get-multi-filter-convnet构建(classifier.clj),完全复刻 Yoon Kim 论文的 "multichannel / multi-filter" 设计:
(defn get-multi-filter-convnet [num-embed sentence-size batch-size vocab-size pretrained-embedding] (let [filter-list [3 4 5] ;; 三种卷积核宽度:3-gram / 4-gram / 5-gram input-x (get-data-symbol num-embed sentence-size batch-size vocab-size pretrained-embedding) polled-outputs (mapv #(make-filter-layers {:input-x input-x :num-embed num-embed :sentence-size sentence-size} %) filter-list) total-filters (* num-filter (count filter-list)) ;; 100 * 3 = 300 concat (sym/concat "concat" nil polled-outputs {:dim 1}) hpool (sym/reshape "hpool" {:data concat :target-shape [batch-size total-filters]}) hdrop (if (pos? dropout) (sym/dropout "hdrop" {:data hpool :p dropout}) hpool) fc (sym/fully-connected "fc1" {:data hdrop :num-hidden num-label})] (sym/softmax-output "softmax" {:data fc})))单条滤波器分支由make-filter-layers实现:卷积 → ReLU 激活 → 全局最大池化。
(defn make-filter-layers [{:keys [input-x num-embed sentence-size] :as config} filter-size] (as-> (sym/convolution {:data input-x :kernel [filter-size num-embed] ;; 核宽度=filter-size,高度=词向量维度 :num-filter num-filter}) data (sym/activation {:data data :act-type "relu"}) (sym/pooling {:data data :pool-type "max" :kernel [(inc (- sentence-size filter-size)) 1] ;; 垂直方向整句池化 :stride [1 1]})))关键点:
- 卷积核形状:
kernel [filter-size num-embed],即宽度为 3/4/5 个词、高度等于词向量维度,每次卷积滑动覆盖一个 n-gram 的完整向量表示; - 池化:池化核高度为
sentence-size - filter-size + 1(恰好覆盖卷积输出的全部时间步),等效于全局最大池化,为每个滤波器输出一个标量; - 拼接:三个分支的池化输出按
dim 1拼接并 reshape 为[batch-size 300]; - Dropout 与分类:以 0.5 的概率做 dropout 防止过拟合,随后经
fully-connected(隐藏单元数 = 标签数 2)和softmax-output输出二分类概率。
三个全局常量定义在网络顶部:num-filter 100(每个分支 100 个滤波器)、num-label 2(正/负两类)、dropout 0.5。
源码剖析:数据加载与预处理流水线
data_helper.clj的load-ms-with-embeddings串起整条数据流水线,按:pretrained-embedding的值分发到不同加载器:
(defn load-ms-with-embeddings [path max-examples embedding-size {:keys [pretrained-embedding] :or {pretrained-embedding nil}}] (let [{:keys [sentences labels]} (load-mr-data-and-labels path max-examples) sentences-padded (pad-sentences sentences) vocab (build-vocab sentences-padded) vocab-embeddings (case pretrained-embedding :glove (->> (load-glove! (glove-file-path embedding-size)) (build-vocab-embeddings vocab embedding-size)) :fasttext (->> (load-fasttext! fasttext-file-path) (build-vocab-embeddings vocab embedding-size)) :word2vec (->> (load-word2vec-model! w2v-file-path embedding-size {:vocab vocab}) (:word2vec) (build-vocab-embeddings vocab embedding-size)) vocab) ;; nil 时直接用词汇表索引 data (build-input-data-with-embeddings sentences-padded vocab-embeddings)] {:data data :label labels :sentence-count (count data) :sentence-size (count (first data)) :embedding-size embedding-size :vocab-size (count vocab) :pretrained-embedding pretrained-embedding}))各步骤的核心逻辑:
1. 读取与清洗(load-mr-data-and-labels)。rt-polarity.pos每行标为 1,rt-polarity.neg每行标为 0;若指定max-examples则正负各取前 N 条。随后clean-str执行一套英文文本规范化规则:非字母数字与(),!?'之外的字符替换为空格、将's/'ve/n't/'re/'d/'ll等缩略形式与标点独立成 token、合并多余空格。
2. 填充到等长(pad-sentences)。以数据集中最长句子的长度为基准,不足部分用句子结束符EOS("</s>")补齐,使每条句子长度一致,才能组成批张量。
3. 构建词汇表(build-vocab)。统计全部词频并按频率降序排序,为每个词分配从 0 开始的唯一索引。
4. 词汇表 → 向量表(build-vocab-embeddings)。对词汇表中的每个词,优先查预训练向量;未出现在预训练文件中的词(OOV)则从[-0.25, 0.25]的均匀分布中随机初始化,保证每个词都有可用的稠密表示:
(defn build-vocab-embeddings [vocab embedding-size embeddings] (into {} (mapv (fn [[word _]] [word (or (get embeddings word) (ndarray/->vec (random/uniform -0.25 0.25 [embedding-size])))]) vocab)))5. 映射为输入张量。build-input-data-with-embeddings把每条填充后的句子逐词替换为对应向量(或索引),最终形成[样本数 1 句长 向量维度]的数据张量。
训练配置:Module 与 Adam 优化器
train-convnet使用 MXNet 的高层 Module API 完成训练。首先构造训练/测试数据迭代器,注意标签名必须与网络输出softmax的标签一致,且末批不足时采用"pad"填充策略:
train-data (mx-io/ndarray-iter [(get-in shuffled [:training :data])] {:label [(get-in shuffled [:training :label])] :label-name "softmax_label" :data-batch-size batch-size :last-batch-handle "pad"}) test-data (mx-io/ndarray-iter [(get-in shuffled [:test :data])] {:label [(get-in shuffled [:test :label])] :label-name "softmax_label" :data-batch-size batch-size :last-batch-handle "pad"})然后以符号图 + 设备列表创建 Module,并调用m/fit训练(eval-data作为每轮结束后的验证集):
(let [mod (m/module (get-multi-filter-convnet embedding-size sentence-size batch-size vocab-size pretrained-embedding) {:contexts devs})] (println "Getting ready to train for " num-epoch " epochs") (println "===========") (m/fit mod {:train-data train-data :eval-data test-data :num-epoch num-epoch :fit-params (m/fit-params {:optimizer (optimizer/adam)})}))示例选用Adam 优化器。若不显式指定,m/fit-params(见 module.clj)的默认值是:eval-metric为 accuracy、kvstore为"local"、optimizer为 SGD、initializer为uniform 0.01,这些默认行为可通过fit-params覆盖。shuffle-data会在训练前打乱数据并按test-size划出训练/测试集。
测试验证:端到端冒烟测试
仓库为示例提供了两个端到端测试(classifier_test.clj),分别覆盖"加载 GloVe 预训练向量"与"无预训练向量(学习嵌入)"两条路径:各用max-examples 1000、num-epoch 1跑一遍完整训练流程,然后断言 Module 的数据名称为["data"]、输出张量长度为 20(= batch-size 10 × 标签数 2):
(deftest classifier-with-embeddings-test (let [train (classifier/train-convnet {:devs [(context/default-context)] :embedding-size 50 :batch-size 10 :test-size 100 :num-epoch 1 :max-examples 1000 :pretrained-embedding :glove})] (is (= ["data"] (util/scala-vector->vec (module/data-names train)))) (is (= 20 (count (ndarray/->vec (-> train module/outputs ffirst)))))))这两个测试既验证了数据流水线与网络定义的正确性,也可作为自定义数据源或网络结构时的回归基线。
参数速查与预期精度汇总
| 预训练向量 | :embedding-size | 数据规模 | 预期验证准确率 | 备注 |
|---|---|---|---|---|
GloVe (:glove) | 50(推荐,也可 100/200/300) | 子集/全量 | — | 文件位于data/glove/glove.6B.{d}d.txt |
fastText (:fasttext) | 300 | 子集 | ~0.67 | 文件位于data/fasttext/wiki.simple.vec |
word2vec (:word2vec) | 300 | 全量 | ~0.78 | 文件位于contrib/clojure-package/data/GoogleNews-vectors-negative300.bin,需-Xmx8g |
无(:pretrained-embedding nil) | 50(自定义) | — | — | 由 embedding 层端到端学习 |
注意事项
- 路径约定:MR 数据集与 GloVe/fastText 文件都放在示例目录(
contrib/clojure-package/examples/cnn-text-classification/)下的data/内;word2vec 二进制文件则放在contrib/clojure-package/data/下(其路径是硬编码的../../data/相对路径)。 - 内存管理:全量数据 + word2vec 场景内存需求最高(README 建议
-Xmx8g);GloVe 子集训练 1GB 堆即可。 - 维度一致性:
:embedding-size必须与预训练文件维度严格匹配(GloVe 50/100/200/300,fastText Simple English 300,GoogleNews word2vec 300),word2vec 加载器会显式校验。 - 设备选择:无 GPU 时
:devs使用(context/default-context)或:cpu N;有 GPU 环境可用:gpu 1/:gpu 2开启多卡并行。
以此示例为起点,你可以通过替换data-helper的加载函数接入任意数据集与词向量,或修改get-multi-filter-convnet的滤波器宽度、数量与池化策略,将其扩展为更复杂的文本分类、情感分析乃至短文本匹配模型。
【免费下载链接】mxnetLightweight, Portable, Flexible Distributed/Mobile Deep Learning with Dynamic, Mutation-aware Dataflow Dep Scheduler; for Python, R, Julia, Scala, Go, Javascript and more项目地址: https://gitcode.com/gh_mirrors/mxnet1/mxnet
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考