前段时刻一向在考虑自己应该做点 AI 相关的工作,考虑来考虑去想法挺多的,但一向都没有付诸实际。不是觉得太难不想着手,就是觉得太简略不值得着手。直到 6 月,自己小有觉悟,觉得即使再容易,也得着手才行。所以,预备从简略的开始,复刻一个 EmojiSearch 的 Android 版本。
体会地址:www.emojisearch.app/ 产品的功用简略易懂:输入一段描绘,来找到对应的 Emoji 表情
事实证明,要做好一个类似的产品,是不容易的,前前后后经历了 2 个月,95h 的工作量。假如只需求做出来,我想一周足够了,可是假如想做好,那么就得考虑到产品的方方面面。怎样节约本钱、怎样进步用户体会、怎样减缩包体积等等,接下来我会和咱们逐个讨论。当然,我做的必定还不够,还有很多能够改进的当地,也欢迎咱们在评论区告诉我~
文章写完,发现自己描绘的仍是比较简略易懂的。但假如想知道,为什么我前前后后花了两个月来做这件工作,能够移步文章最终的待优化及踩坑项一探究竟。
在开始之前,我还想啰嗦两句,关于为什么挑选做这个 Project。首先这个 Project “看起来” 比较简略,网页版的作者是 lilianweng,自己在 18 年拜读大佬 Policy Gradient 的 blog,受益匪浅(云里雾里)。想着在大佬搭好的脚手架上做,应该不成问题。
APP 功用预览
项目源码:github.com/sunnyswag/e…
体会 APP 下载:github.com/sunnyswag/e…
License:MIT License
项目时序简介
项目的构建分为两个部分,预备数据和构建 Android APP,具体的进程如下:
- 预备数据
- 用 Python 来解析并获取到 Emoji 的数据
- 向 OpenAI 发送 POST 恳求,获取 Emoji 的 Embedding 数据
- 将 Emoji 数据保存为 Json 格局的文件
- 将数据复制到 Android APP,并将数据转化为 Protocol Buffers 的格局
- 构建 Android APP
- 加载 Emoji 数据到内存
- 处理用户输入,拿到用户输入的 Embeddings
- 将 Embeddings 和 Emoji 的 Embedding 做点积运算,得到前 20 个最相关的 Emoji
接下来,咱们将会依照项目的构建进程来逐个了解,项目是怎样一步一步构建完的。
数据预备
解析获取 Emoji 数据
Emoji 的数据来历有两个,一个是 UniCode 官网,别的一个是 Python 的 emoji 库,在拿到这两部分的数据之后对 Emoji 数据进行整合,得到如下格局的数据:
emojis_full_msg_dedupe = {
"": "grinning face",
"": "grinning face with big eyes",
"": "beaming face",
// other emojis...
}
将如上的数据再转化成自然言语的描绘,作为 embedding 的输入数据:
The emoji is about grinning face.
The emoji is about grinning face with big eyes.
The emoji is about beaming face.
如上解析操作的代码能够检查 build_emoji_data.py/extract_emo…
获取 Emoji 的 Embedding 数据
获取 Embedding 数据其实也比较简略,完成起来就是 Python 对 OpenAI API 的调用:
def get_embeddings(inps: List[str], batch: int=1000) -> List[List[float]]:
i = 0
outputs = []
while i < len(inps):
result = openai.Embedding.create(input=inps[i:i+batch], model=EMBEDDING_MODEL)
outputs += [x["embedding"] for x in result['data']]
i += batch
assert len(outputs) == len(inps)
return outputs
保存 Emoji 数据为 Json 格局的文件
最终输出的数据格局为如下,保存为 json.zip 格局即可
// 文件行数为:3753
// embed 向量维度为:1536
{"emoji": "\ud83e\udd47", "message": "1st place medal", "embed": [-0.018469301983714104, -0.004823130089789629, ...]}
{"emoji": "\ud83e\udd48", "message": "2nd place medal", "embed": [-0.023217657580971718, -0.0019081177888438106, ...]}
将 Emoji 数据转化为 Protocol Buffers 格局
由于 Protocol Buffers 是二进制格局的文件,比基于文本存储的 Json 文件占用空间更小,读取速度也更快。所以在移动设备上,我这儿挑选运用 Protocol Buffers 格局进行存储。具体的测验及比照成果,能够检查之前写的 Android 当你需求读一个 47M 的 json.gz 文件。
这儿咱们运用 Kotlin 完成一下 Json → Protocol Buffers 的转化,Why Kotlin?,能够检查一下 待优化及踩坑项。
private val pbEntityCollection = mutableListOf<EmojiEmbeddingOuterClass.EmojiEmbedding>()
override suspend fun process(context: Context) = withContext(Dispatchers.IO) {
context.resources.openRawResource(R.raw.emoji_embeddings_json).use { inputStream ->
GZIPInputStream(inputStream).bufferedReader().useLines { lines ->
lines.forEach { line ->
val entity = gson.fromJson(line, EmojiEmbeddingEntity::class.java)
val pbEntity = EmojiEmbeddingOuterClass.EmojiEmbedding.newBuilder()
.setEmoji(entity.emoji)
.setMessage(entity.message)
.addAllEmbed(entity.embed.toList())
.build()
pbEntityCollection.add(pbEntity)
}
}
}
saveToProtoBuf(context)
}
private fun saveToProtoBuf(context: Context) {
val fileStream = context.openFileOutput("emoji_embeddings_proto.gz", Context.MODE_PRIVATE)
GZIPOutputStream(fileStream).use { gzipOutputStream ->
try {
pbEntityCollection.forEach {
it.writeDelimitedTo(gzipOutputStream)
}
} catch (e: IOException) {
e.printStackTrace()
}
}
}
这儿的代码阅览起来我想应该是不难的,比较关键的是 writeDelimitedTo
这个办法,它会将当时字段的长度存储在字段的头部,便利之后的读取工作。
Android APP 的构建
加载 Emoji 数据
由于 Emoji 的数据有必要 APP 发动后就能够读取到,所以我运用了 Jetpack 的 Startup 来加载。
class AppInitializer : Initializer<Unit> {
private val initializerScope = CoroutineScope(Dispatchers.Default)
@OptIn(ExperimentalTime::class)
override fun create(context: Context) {
initializerScope.launch {
measureTime { readEmojiEmbeddings(context) }
}
}
private suspend fun readEmojiEmbeddings(context: Context) {
ProcessorFactory.doProcess(
context,
ProcessorType.PROTOBUF_PROCESSOR,
listOf(R.raw.emoji_embeddings_proto)
)
}
}
加载进程中,运用 parseDelimitedFrom
,该办法和 writeDelimitedTo
对应起来了。读取的原理和写入的相对应,先读取字段长度,依据长度读取对应的字段即可。
这儿用到了多线程,读取在 IO 线程,数据解析在 Default 线程。
解析的进程,运用 flatMapMerge
敞开了多个协程,进步解析的速度。
private var index = AtomicInteger(0)
override suspend fun process(context: Context) = withContext(Dispatchers.Default) {
flow {
context.resources.openRawResource(R.raw.emoji_embeddings_proto).use { inputStream ->
GZIPInputStream(inputStream).buffered().use { gzipInputStream ->
while (true) {
EmojiEmbeddingOuterClass.EmojiEmbedding.parseDelimitedFrom(gzipInputStream)?.let {
emit(it)
} ?: break
}
}
}
}.flowOn(Dispatchers.IO)
.buffer()
.flatMapMerge { byteArray ->
flow { emit(readEmojiData(byteArray)) }
}.collect {}
}
private fun readEmojiData(entity: EmojiEmbeddingOuterClass.EmojiEmbedding) {
val currentIdx = index.getAndIncrement()
// read data
}
Compose UI
整个 APP 的界面长这样:
都是一些十分简略的 Compose UI 元素构建,整体布局为 TextField
+ LazyColumn
的组合。
如篇头的时序图所示,用户输入相关查找内容,恳求得到成果之后,运用 MVVM,经过 UiState
的方式来更新 UI。界说了如下的 UiState
:
sealed class UiState {
object Loading: UiState()
data class Success(val data: List<EmojiInfoEntity>): UiState()
data class Error(@StringRes val message: Int): UiState()
object Default: UiState()
}
-
沉溺式状态栏
运用了 accompanist-systemuicontroller 库,完成起来既简略又便利。在 Theme 中书写如下代码,即可完成沉溺态:
val systemUiController = rememberSystemUiController() SideEffect { systemUiController.setSystemBarsColor( color = colorScheme.background ) }
-
查找逻辑的书写
Compose 的
TextField
运用起来也十分便利,假如TextField
是用来做查找,那么只需求如下界说即可,软键盘承认按钮的位置会变为查找的 Button,点击后也会执行相对应的操作。TextField( // other parameters ... keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearch(searchText.text) }) )
运用 OpenAI API 发送网络恳求
调用接口获取用户输入所对应的 Embedding,都是一些通用的操作,不过参考了第三方 SDK github.com/aallam/open… 的完成,减缩了一部分工作量。
网络接口代码也简略贴一下吧,API_KEY
放在了 BuildConfig
里边:
interface OpenAIAPI {
@Headers(
"Content-Type:application/json",
"Authorization:Bearer ${BuildConfig.API_KEY}"
)
@POST("v1/embeddings")
suspend fun getEmbedding(@Body request: EmbeddingRequest): EmbeddingResponse
}
计算得出最相关的 Emojis
从 OpenAI API 拿到用户输入的 Embedding 数据之后,需求和之前的 Protocol Buffers 格局的 Emojis Embedding 数据做一次矩阵点积运算,得到的点积越大,即和用户输入的类似度越高(假如两个向量的点积越大,则标明两个向量的类似度越高)。具体能够表明成如下公式所示:
代码完成上,则运用到了 github.com/Kotlin/mult… 这个 Kotlin 官方的矩阵运算库。运用其供给的数据结构存储 Embedding 数据后,调用其 dot
办法即可:
const val EMOJI_EMBEDDING_SIZE = 3753
const val EMBEDDING_LENGTH_PER_EMOJI = 1536
val emojiEmbeddings = mk.zeros<Float>(EMOJI_EMBEDDING_SIZE, EMBEDDING_LENGTH_PER_EMOJI)
val embeddingReshaped = mk.ndarray(embedding).reshape(EMBEDDING_LENGTH_PER_EMOJI, 1)
val dotResult = emojiEmbeddings.dot(embeddingReshaped).flatten().toList()
result = topKIndices(dotResult, topK)
fun topKIndices(list: List<Float>, k: Int): List<Int> {
val indices = List(list.size) { index -> index }
return indices.sortedByDescending { list[it] }.take(k)
}
待优化及踩坑项
运用后端服务器做分发,完成国内的直接拜访
当时情况下,运用 APP 时,需求敞开科学上网。假如能够建立一个国外服务器作为跳板机来拜访 OpenAI 的 API,那么国内用户就能够直接运用了,确实能极大的进步用户体会。所以我参考 相关教程 ,测验运用腾讯云函数,用 Python 简略建立一下。
测验了硅谷和新加坡的服务器,运用如下命令测验,每次恳求都是返回 443 Timeout,根本没办法拜访,考虑到 OpenAI API 在国内的诸多限制,遂抛弃了,其他家估量也很难保持稳定。
curl https://api.openai.com/v1/embeddings \
-H "Content-Type: application/json" \
-H "Authorization: Bearer API_KEY" \
-d '{
"input": "Your text string goes here",
"model": "text-embedding-ada-002"
}'
运用 Mobile-Bert,完本钱地化布置
实际上,完成一个 Emoji Search 的功用,彻底能够自己练习一个 Embedding 模型,所以在这个方向上我也花了大把时刻进行测验。
想法也十分简略直接,运用 Mobile-Bert 来完本钱地推理,这样我就能够不需求调用 OpenAI 的 API 了。
赶忙运用预练习的 Mobile-Bert 测验了一下,成果发现不论我查找什么,输出的都是这些内容:
get_top_relevant_emojis, ind: [ 661 1529 2221 564 3114]
result: [ {'emoji': '', 'message': 'Control knobs', 'score': 1.0089411635249676e+16}, {'emoji': '', 'message': 'Closed lock with key', 'score': 9776858819601088.0}, {'emoji': '', 'message': 'Open file folder', 'score': 9320027788615100.0}, {'emoji': '', 'message': 'Card file box', 'score': 8816309843148762.0}, {'emoji': '™', 'message': 'Trade mark sign', 'score': 8319274302748725.0}]
看了这篇回答,预练习的 Bert 模型,对 emoji 支撑并不友爱,Emoji 相关的数据太少了。接下来,又测验了一波 DistilRoBERTa。作用依旧很差,假如需求完本钱地推理的话,需求自己搜集 Emoji 数据来练习了。考虑到时刻本钱,预备先抛弃本地推理的计划。不过以后自己必定需求去做这些的!
下降 Embedding 的维度
经过 OpenAI API 得到的 Embedding 向量维度为1536,其实针对只要 3000 多条 Emoji 数据进行查找的场景,1536 维确实没有必要。所以我测验运用 PCA
算法对 Embedding 数据完成降维操作。发现降到 100 维的姿态,能保持相关于原始数据 0.77650886 的准确率。这关于当时场景来说,彻底足够了。
可是,降维之后,我需求保存并加载 PCA
的相关权重文件,虽然能够削减较多的包体积,可是会把数据加载的进程弄得更加复杂,更难维护,所以就没有实际在 APP 上完成了,只运用 Python 进行了简略的测验。
Python 端直接保存成 Protocol Buffers 文件
在 将 Emoji 数据转化为 Protocol Buffers 格局 那一节,我运用 Kotlin 将 Json 格局的数据转化为 Protocol Buffers 格局。其实 Protocol Buffers 作为支撑众多言语的一个二进制文件存储协议,运用 Python 转化,Kotlin 读取会更加便利。
而转化和读取都运用 Kotlin 的原因,是由于 Python 的 float
类型默认为双精度浮点数,占用 8 个 Byte,Kotlin 的 float
是单精度浮点数,占用 4 字节(32 位)的内存。举个简略的比如:
有如下的数据需求运用 Protocol Buffers 存储
{"embed": [1.1, 1.1]}
运用 Python 序列化后得到的 Byte 数组:
26, 16, 154, 153, 153, 153, 153, 153, 241, 63, 154, 153, 153, 153, 153, 153, 241, 63
运用 Kotlin 序列化后得到的 Byte 数组:
26, 8, 205, 204, 140, 63, 205, 204, 140, 63
第一个 Byte 26 表明的是数据类型,第二个 Byte 16 和 8 表明数据的长度,由所以 2 个 Float 数字,所以在 Python 中的巨细为 16,而在 Kotlin 中占用的巨细为 8。
Android 端矩阵运算 SDK 的挑选
这儿虽然不是什么大规模的矩阵运算,可是为了进步矩阵运算的速度,仍是有一些值得取舍的点。
是运用 Koltin 原生,仍是运用 Native,亦或是运用 Pytorch Mobile 或许 RenderScript。
这儿我想的是先不要搞那么复杂,先不考虑自己去跑 Benchmark 然后决议运用哪个,直接运用 Kotlin 官方的,运用 Native 办法完成的库 viktor。RenderScript 适用于图画图画处理,视频修改等。Pytorch 适用于大规模的矩阵运算,并且会增加包体积。单次的简略的矩阵运算,我想,我想运用 Native 办法完成的 viktor 现已够用了。
-
viktor 的运用
看到这张图,我整个人直接沉默了,viktor 库的 dot 办法,不持支多维向量,只支撑一维向量和一维向量之间求点积!只能再找找其他的了。
-
multik 的运用
同样是 Kotlin 官方的库, multik 比 viktor 要好很多,至少支撑多维向量的点积了。可是没办法用 Native 的办法,由于 Native 库还没有编译 Windows 平台的。运用的是 Kotlin 原生进行计算,每次 reshape + dot 运算大约花费 70ms 的姿态,是彻底能够接受的。
-
运用 Kotlin 代码
简略完成了一下 Kotlin 的矩阵点积操作:
val emojiEmbeddings = Array(EMOJI_EMBEDDING_SIZE) { FloatArray(EMBEDDING_LENGTH_PER_EMOJI) } fun calculateDot(embeddings: Array<FloatArray>, resEmbedding: FloatArray): FloatArray { val result = FloatArray(embeddings.size) embeddings.forEachIndexed { index, embedding -> result[index] = embedding.zip(resEmbedding).fold(0f) { res, cur -> res + cur.first * cur.second } } return result }
和 multik 比起来,差距仍是蛮大的,Kotlin 的完成耗时在 700ms,是 multik 的 10 倍。
Reference
Emoji Search
github.com/lilianweng/…
UTS #51: Unicode Emoji
github.com/carpedm20/e…
Flowchart Maker & Online Diagram Software
Android 当你需求读一个 47M 的 json.gz 文件 –
github.com/aallam/open…
腾讯云函数1分钟建立 OpenAI 国内署理
github.com/Kotlin/mult…
github.com/JetBrains-R…
github.com/zenled/Emoj…
fonts.google.com/icons