前段时刻一向在考虑自己应该做点 AI 相关的工作,考虑来考虑去想法挺多的,但一向都没有付诸实际。不是觉得太难不想着手,就是觉得太简略不值得着手。直到 6 月,自己小有觉悟,觉得即使再容易,也得着手才行。所以,预备从简略的开始,复刻一个 EmojiSearch 的 Android 版本。

体会地址:www.emojisearch.app/ 产品的功用简略易懂:输入一段描绘,来找到对应的 Emoji 表情

使用 OpenAI API 实现 Emoji Search 的 Android APP

事实证明,要做好一个类似的产品,是不容易的,前前后后经历了 2 个月,95h 的工作量。假如只需求做出来,我想一周足够了,可是假如想做好,那么就得考虑到产品的方方面面。怎样节约本钱、怎样进步用户体会、怎样减缩包体积等等,接下来我会和咱们逐个讨论。当然,我做的必定还不够,还有很多能够改进的当地,也欢迎咱们在评论区告诉我~

文章写完,发现自己描绘的仍是比较简略易懂的。但假如想知道,为什么我前前后后花了两个月来做这件工作,能够移步文章最终的待优化及踩坑项一探究竟。

在开始之前,我还想啰嗦两句,关于为什么挑选做这个 Project。首先这个 Project “看起来” 比较简略,网页版的作者是 lilianweng,自己在 18 年拜读大佬 Policy Gradient 的 blog,受益匪浅(云里雾里)。想着在大佬搭好的脚手架上做,应该不成问题。

APP 功用预览

项目源码:github.com/sunnyswag/e…

体会 APP 下载:github.com/sunnyswag/e…

License:MIT License

项目时序简介

使用 OpenAI API 实现 Emoji Search 的 Android APP

项目的构建分为两个部分,预备数据和构建 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 的界面长这样:

使用 OpenAI API 实现 Emoji Search 的 Android 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 数据做一次矩阵点积运算,得到的点积越大,即和用户输入的类似度越高(假如两个向量的点积越大,则标明两个向量的类似度越高)。具体能够表明成如下公式所示:

A∈R37531536,B∈R15361,C∈R37531C=A⋅B=∣∣A∣∣ ∣∣B∣∣ cos⁡(x)\textbf{A} \in \mathbb{R}^{3753 \times 1536}, \textbf{B} \in \mathbb{R}^{1536 \times 1}, \textbf{C} \in \mathbb{R}^{3753 \times 1} \\ \textbf{C} = \textbf{A} \cdot \textbf{B} = ||\textbf{A}|| \, ||\textbf{B}|| \, \cos(\textbf{x})

代码完成上,则运用到了 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 的运用

    使用 OpenAI API 实现 Emoji Search 的 Android APP

    看到这张图,我整个人直接沉默了,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