一、从“看懂文字”到“理解语义”——NLP 在 Android 上的落地
说实话,很多做 Android 的同学一听到“自然语言处理”,第一反应就是“那是后端和算法团队的事”。我以前也这么想。直到有一次,我需要在 App 里做一个实时评论审核功能——用户发一条评论,本地就要判断它是不是违规,不能等网络请求回来。嗯,那时候我才意识到,移动端 NLP 不是“能不能做”的问题,而是“怎么做才又快又准”。
这一章,我们就来实战一个典型的 NLP 任务:文本分类。我会用 BERT Tiny 这个轻量级模型,带你走通 Tokenization、序列处理、模型推理的完整链路。你想想看,一个能离线判断“好评/差评”的模型,跑在你的手机上,是不是挺酷的?
- BERT Tiny 模型结构 & 为什么选它
- Tokenization:从文字到数字 ID
- 序列处理:Padding、Truncation、Attention Mask
- Android NNAPI 推理实战
二、为什么选 BERT Tiny?
标准的 BERT Base 有 1.1 亿参数,在手机上跑一次推理可能要好几秒,而且内存占用感人。我曾在项目里试过直接部署 BERT Base,结果 App 启动时加载模型就 OOM 了……后来换成了 BERT Tiny,参数量只有 440 万,推理速度提升了 10 倍以上,准确率只下降了不到 3 个百分点。说白了,在移动端,速度就是体验。
| 模型 | 参数量 | 推理时间 (Pixel 4) | 准确率 (IMDb) |
|---|---|---|---|
| BERT Base | 110M | ~3200ms | 93.5% |
| BERT Tiny | 4.4M | ~280ms | 90.8% |
| DistilBERT | 66M | ~1800ms | 92.1% |
三、Tokenization:把文字变成模型能懂的“数字”
模型不认识“这部电影真好看”,它只认识数字。Tokenization 就是做这个翻译工作的。BERT 用的是 WordPiece 分词,它会先把句子拆成单词或子词,然后映射到词表中的 ID。
举个例子:
输入: "这部电影真好看"
分词后: ["这", "部", "电影", "真", "好", "看"]
加上特殊标记: ["[CLS]", "这", "部", "电影", "真", "好", "看", "[SEP]"]
Token IDs: [101, 6821, 4692, 3125, 2535, 1963, 3341, 102]
这里 [CLS] 是分类标记,[SEP] 是句子分隔符。BERT 会用 [CLS] 对应的输出向量来做分类。我在项目中遇到过一个问题:中文分词时,有些生僻字不在词表里。WordPiece 会把它拆成更小的子词,比如“魑”可能被拆成“##魑”或者 UNK(未知标记)。所以,一定要检查你的词表覆盖度。
四、序列处理:让所有输入“整齐划一”
模型要求输入是固定长度的张量。但句子有长有短,怎么办?两个操作:Padding(填充)和Truncation(截断)。
假设我们设定最大序列长度为 128:
- 句子长度 < 128:在末尾补 0(Padding),直到长度达到 128
- 句子长度 > 128:从末尾截断,只保留前 128 个 token
但这里有个坑:模型怎么知道哪些是真实 token,哪些是填充的? 这就需要 Attention Mask 了。它是一个和输入等长的 0/1 数组,真实 token 的位置为 1,填充位置为 0。模型在计算注意力时,会忽略 mask 为 0 的位置。
// 假设原始 token IDs: [101, 6821, 4692, 102]
// 最大长度: 8
// 经过 Padding 后:
input_ids: [101, 6821, 4692, 102, 0, 0, 0, 0]
attention_mask: [1, 1, 1, 1, 0, 0, 0, 0]
五、Android NNAPI 推理实战
好了,前面都是准备工作。现在我们来写代码。假设你已经把 BERT Tiny 模型转换成了 .tflite 格式(用 TensorFlow Lite Converter),并且导入了 NNAPI 的依赖。
5.1 加载模型并创建解释器
// 加载模型文件
ByteBuffer modelBuffer = loadModelFile(context, "bert_tiny.tflite");
Interpreter interpreter = new Interpreter(modelBuffer);
// 启用 NNAPI 加速
Interpreter.Options options = new Interpreter.Options();
options.setUseNNAPI(true); // 关键:告诉解释器使用硬件加速
Interpreter nnapiInterpreter = new Interpreter(modelBuffer, options);
5.2 准备输入数据
// 假设我们已经完成了 Tokenization 和 Padding
// inputIds: int[1][128] — token IDs
// attentionMask: int[1][128] — attention mask
int[][] inputIds = new int[1][128];
int[][] attentionMask = new int[1][128];
// 填充数据(这里省略 Tokenization 细节)
// ...
// 创建输入张量
TensorBuffer inputFeature0 = TensorBuffer.createFixedSize(
new int[]{1, 128}, DataType.FLOAT32);
inputFeature0.loadArray(inputIds);
TensorBuffer inputFeature1 = TensorBuffer.createFixedSize(
new int[]{1, 128}, DataType.FLOAT32);
inputFeature1.loadArray(attentionMask);
5.3 执行推理并解析结果
// 输出张量:二分类,shape 为 [1, 2]
float[][] output = new float[1][2];
Object[] inputs = {inputIds, attentionMask};
Map outputs = new HashMap<>();
outputs.put(0, output);
// 执行推理
nnapiInterpreter.runForMultipleInputsOutputs(inputs, outputs);
// 解析结果
float positiveProb = output[0][1]; // 正面概率
float negativeProb = output[0][0]; // 负面概率
if (positiveProb > negativeProb) {
// 分类为正面
Log.d("NLP", "好评,置信度: " + positiveProb);
} else {
// 分类为负面
Log.d("NLP", "差评,置信度: " + negativeProb);
}
六、完整流程总结
来,我们捋一遍整个流程:
- 输入文本 → 比如“这部电影太棒了”
- Tokenization → 拆成 token,加上 [CLS] 和 [SEP]
- 序列处理 → Padding 到固定长度,生成 Attention Mask
- NNAPI 推理 → 加载模型,传入 input_ids 和 attention_mask
- 输出解析 → 取 softmax 后的概率,判断分类
整个过程,从用户输入到拿到分类结果,在 Pixel 4 上实测大约 280ms。如果你用 NNAPI 的 GPU 加速,还能再快 30% 左右。嗯,这个速度已经可以满足大多数实时场景了。
公众号:蓝海资料掘金营,微信deep3321