5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ニューラル言語モデル zenz を Android で使用する方法

5
Posted at

はじめに

Android 用の OSS キーボードアプリ「Sumire」の開発をしている Kazuma.N です。アプリのバージョン 1.4.475 からニューラル言語モデル zenz-v3.1xsmall を搭載しました。本記事では、この zenz を Android 上で実際に動かすまでの手順 を、私が Sumire に組み込んだときの実例を交えながら解説します。

ニューラル言語モデル zenz について

zenz は、Miwa Keita 氏 と Takahashi Naoki 氏が開発・公開している、日本語のかな漢字変換に特化したニューラル言語モデル群 です。
独立行政法人情報処理推進機構(IPA)の 2024 年度 未踏 IT 人材発掘・育成事業「ニューラル言語モデルによる個人最適な日本語入力システムの開発」 に採択されたプロジェクトでも中核技術として用いられており、学術的にも実用的に高い評価を受けています。(PDF)

ベースには GPT-2 系の文字レベル日本語モデルが使われており、MeCab + ipadic-NEologd などで読みを付与した数百万文規模のデータで転移学習が行われています。(Hugging Face)
その結果、従来型のかな漢字変換エンジンや Google 日本語入力 API を上回る精度を達成しつつも、モデルサイズは数十 M パラメータ程度に抑えられており、ローカル環境でのリアルタイム推論に適した設計になっています。(Zenn)

今回の Sumire では、この zenz シリーズの中でも小型の zenz-v3.1-xsmall-gguf を採用し、Android デバイス上で完結するオフラインかな漢字変換 を実現しています。

ノートパソコンで試してみる

まずは Android で zenz を動かす前にノートパソコンで動かすことができるか試しました。

環境

  • WSL (Ubuntu) / Windows 11
  • Ubuntu 20.04.3 LTS (GNU/Linux 6.6.87.2-microsoft-standard-WSL2 x86_64)

1. WSL にビルドツールをインストールする

sudo apt update
sudo apt install -y git build-essential cmake

2. llama.cpp を clone してビルドする

git clone https://github.com/ggml-org/llama.cpp.git
cd llama.cpp
cmake -B build
cmake --build build --config Release -j$(nproc)

3. zenz をダウンロードして使用するパスに配置する

# ~/models/zenz に配置
~/models/zenz/ggml-model-Q5_K_M.gguf

4. zenz を llama.cpp を使用して実行する

cd ~/llama.cpp/build/bin

./llama-cli \
  -m ~/models/zenz/ggml-model-Q5_K_M.gguf \
  --ctx-size 256 \
  --n-predict 64

このコマンドを実行すると以下のエラーが出ます。

llama_model_load: error loading model: error loading model vocabulary: unknown pre-tokenizer type: 'gpt2-small-japanese-char'
llama_model_load_from_file_impl: failed to load model

簡単に説明すると pre-tokenizer typegpt2-small-japanese-char が定義されていないことによるエラーです。zenz を使用するために llama.cpp に修正を加えたフォークを使用する必要があります(Zenn)。

5. フォークされた llama.cpp をクローンしてビルドする

git clone https://github.com/azooKey/llama.cpp.git
cd llama.cpp
# CMake でビルド (CPUのみの例)
cmake -S . -B build -DGGML_CUDA=OFF
cmake --build build -j"$(nproc)"
# 実行する
./build/bin/llama-cli \
  -m ~/models/zenz/ggml-model-Q5_K_M.gguf \
  --ctx-size 256 \
  --n-predict 64 \
  -p $'\uEE00セイドガタカイ\uEE01'
結果
セイドガタカイ 精度が高い [end of text]

WSL 上で zenz を使用してかな漢字変換ができました。

llama.cpp を Android で使えるようにする

Android で zenz を動かすには NDK で libllama.so をビルドして、 Kotlin から JNI で呼ぶ必要があります。
NDK は 29.0.14206865 を使用しました。(Latest stable version of R29 2025年11月時点)

1. NDK で azooKey/llama.cpp を Android 用にビルドする

mkdir -p ~/Android/Sdk/ndk
cd ~/Android/Sdk/ndk


# まだなら Linux 版 NDK を直接ダウンロード
wget https://dl.google.com/android/repository/android-ndk-r29-linux.zip
unzip android-ndk-r29-linux.zip

# わかりやすくリネーム
mv android-ndk-r29 android-ndk-r29-linux

# ANDROID_NDK をセット
export ANDROID_NDK="$HOME/Android/Sdk/ndk/android-ndk-r29-linux"

# フォークされt llama.cpp に移動する
cd ~/llama.cpp

# NDK を使用してビルドする
cmake -S . -B build-android \
  -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK/build/cmake/android.toolchain.cmake" \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_PLATFORM=android-28 \
  -DGGML_OPENMP=OFF \
  -DGGML_LLAMAFILE=OFF
結果
-- Setting GGML_NATIVE_DEFAULT to OFF
-- Warning: ccache not found - consider installing it for faster compilation or disable this warning with GGML_CCACHE=OFF
-- CMAKE_SYSTEM_PROCESSOR: aarch64
-- Including CPU backend
-- ARM detected
-- Adding CPU backend variant ggml-cpu:
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/llama.cpp/build-android

build-android/bin/ に以下のファイルが作成されます

  • libggml.so
  • libggml-base.so
  • libggml-cpu.so
  • libllama.so
  • libllava_shared.so

2. Android の新規プロジェクトを作成する

zenz のデモ用のアプリを Android Studio で新規作成します。
プロジェクトを新規作成後に zenz をライブラリとして新規 module を作成しました。

3. llama.cpp のヘッダファイルをコピーする

zenz/src/main/cpp/ に以下のファイルをコピーします。

  • ggml-alloc.h
  • ggml-backend.h
  • ggml-cpu.h
  • ggml.h
  • llama.h

ZooKeyKanaKanjiConverter からヘッダファイルをコピーさせて頂きました。

4. CMakeLists.txt を作成する

以下のように作成しました。
今回のデモではビルド済みの so ファイルをライブラリとして使用します。ブリッジとして zenz_bridge.cpp を後ほど作成します。

cmake_minimum_required(VERSION 3.22.1)
project(zenz_core LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# CMAKE_SOURCE_DIR = zenz/src/main/cpp を想定
# jniLibs         = zenz/src/main/jniLibs/${ANDROID_ABI}
set(LIB_DIR ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})

# 事前ビルド済みの .so を IMPORTED ライブラリとして扱う
add_library(ggml SHARED IMPORTED)
set_target_properties(ggml PROPERTIES IMPORTED_LOCATION
        ${LIB_DIR}/libggml.so)

add_library(ggml-base SHARED IMPORTED)
set_target_properties(ggml-base PROPERTIES IMPORTED_LOCATION
        ${LIB_DIR}/libggml-base.so)

add_library(ggml-cpu SHARED IMPORTED)
set_target_properties(ggml-cpu PROPERTIES IMPORTED_LOCATION
        ${LIB_DIR}/libggml-cpu.so)

add_library(llama SHARED IMPORTED)
set_target_properties(llama PROPERTIES IMPORTED_LOCATION
        ${LIB_DIR}/libllama.so)

# JNI ブリッジ本体(ライブラリ名: zenz)
add_library(zenz SHARED
        zenz_bridge.cpp
)

# llama.h / ggml*.h が置いてあるフォルダを include path に追加
# (ggml-backend.h, ggml-cpu.h, ggml.h, llama.h が src/main/cpp にある想定)
target_include_directories(zenz PRIVATE
        ${CMAKE_SOURCE_DIR}
)

# Android の log ライブラリ
find_library(log-lib log)

# Android の log ライブラリ + llama/ggml にリンク
target_link_libraries(zenz
        PRIVATE
        llama
        ggml
        ggml-base
        ggml-cpu
        ${log-lib}
)

5. zenz_bridge.cpp の作成

Kotlin から llama.cpp を使用するために JNI を作成します。この記事では主な関数のみを紹介します。

モデルの初期の読み込み
extern "C"
JNIEXPORT void JNICALL
Java_com_kazumaproject_zenz_ZenzEngine_initModel(
        JNIEnv *env,
        jobject /* thiz */,
        jstring jModelPath
) {
    const char *c_model_path = env->GetStringUTFChars(jModelPath, nullptr);
    LOGI("initModel: %s", c_model_path);

    // 再 init 時のクリーンアップ
    if (g_model) {
        llama_model_free(g_model);
        g_model = nullptr;
        g_vocab = nullptr;
    }

    llama_backend_init();

    llama_model_params mparams = llama_model_default_params();
    mparams.n_gpu_layers = 0;   // Android は CPU 前提
    mparams.use_mmap = true;

    g_model = llama_model_load_from_file(c_model_path, mparams);
    if (!g_model) {
        LOGE("Failed to load model");
        env->ReleaseStringUTFChars(jModelPath, c_model_path);
        return;
    }

    g_vocab = llama_model_get_vocab(g_model);
    if (!g_vocab) {
        LOGE("Failed to get vocab");
        llama_model_free(g_model);
        g_model = nullptr;
        env->ReleaseStringUTFChars(jModelPath, c_model_path);
        return;
    }

    env->ReleaseStringUTFChars(jModelPath, c_model_path);
}
zenz を使用した変換結果を取得する
extern "C"
JNIEXPORT jstring JNICALL
Java_com_kazumaproject_zenz_ZenzEngine_generateWithContextAndConditions(
        JNIEnv *env,
        jobject /* thiz */,
        jstring jProfile,
        jstring jTopic,
        jstring jStyle,
        jstring jPreference,
        jstring jLeftContext,
        jstring jInput
) {
    if (!g_model || !g_vocab) {
        return env->NewStringUTF("Model not initialized");
    }

    // JNI 文字列を取得(null 許容)
    const char *c_profile = jProfile ? env->GetStringUTFChars(jProfile, nullptr) : nullptr;
    const char *c_topic = jTopic ? env->GetStringUTFChars(jTopic, nullptr) : nullptr;
    const char *c_style = jStyle ? env->GetStringUTFChars(jStyle, nullptr) : nullptr;
    const char *c_preference = jPreference ? env->GetStringUTFChars(jPreference, nullptr) : nullptr;
    const char *c_left = jLeftContext ? env->GetStringUTFChars(jLeftContext, nullptr) : nullptr;
    const char *c_input = jInput ? env->GetStringUTFChars(jInput, nullptr) : nullptr;

    std::string profile = c_profile ? c_profile : "";
    std::string topic = c_topic ? c_topic : "";
    std::string style = c_style ? c_style : "";
    std::string preference = c_preference ? c_preference : "";
    std::string left = c_left ? c_left : "";
    std::string input = c_input ? c_input : "";

    if (c_profile) env->ReleaseStringUTFChars(jProfile, c_profile);
    if (c_topic) env->ReleaseStringUTFChars(jTopic, c_topic);
    if (c_style) env->ReleaseStringUTFChars(jStyle, c_style);
    if (c_preference) env->ReleaseStringUTFChars(jPreference, c_preference);
    if (c_left) env->ReleaseStringUTFChars(jLeftContext, c_left);
    if (c_input) env->ReleaseStringUTFChars(jInput, c_input);

    // Zenz v3 で使われるタグ
    const std::string inputTag = u8"\uEE00";
    const std::string contextTag = u8"\uEE02";
    const std::string profileTag = u8"\uEE03";
    const std::string topicTag = u8"\uEE04";
    const std::string styleTag = u8"\uEE05";
    const std::string preferenceTag = u8"\uEE06";
    const std::string outputTag = u8"\uEE01";

    // conditions を連結
    std::string conditions;
    if (!profile.empty()) {
        conditions += profileTag + profile;
    }
    if (!topic.empty()) {
        conditions += topicTag + topic;
    }
    if (!style.empty()) {
        conditions += styleTag + style;
    }
    if (!preference.empty()) {
        conditions += preferenceTag + preference;
    }

    // プロンプト構築
    std::string prompt;
    if (!left.empty()) {
        // 文脈あり: conditions + contextTag + left + inputTag + input + outputTag
        prompt = conditions + contextTag + left + inputTag + input + outputTag;
    } else {
        // 文脈なし: conditions + inputTag + input + outputTag
        prompt = conditions + inputTag + input + outputTag;
    }

    std::string result = pure_greedy_decoding(prompt, /*maxCount=*/32);

    return env->NewStringUTF(result.c_str());
}

6. ZenzEngine の作成

JNI 関数を作成したので次に native library を使用するための Kotlin 側でラッパを作成する必要があります。

package com.kazumaproject.zenz

object ZenzEngine {

    init {
        // CMake の add_library(zenz SHARED ...) と一致させる
        System.loadLibrary("zenz")
    }

    external fun initModel(modelPath: String)
    external fun generate(prompt: String): String
    external fun generateWithContext(leftContext: String, input: String): String
    external fun generateWithContextAndConditions(
        profile: String,
        topic: String,
        style: String,
        preference: String,
        leftContext: String,
        input: String
    ): String
}

7. zenz の build.gradle を修正する

以下の設定を追加します。

android {
    defaultConfig {
        ndk {
            abiFilters "arm64-v8a"
        }
    }

    // CMake でネイティブライブラリをビルド
    externalNativeBuild {
        cmake {
            path "src/main/cpp/CMakeLists.txt"
        }
    }
}

8. MainActivity で使用する

val modelFile = copyModelFromAssetsIfNeeded(modelAssetFileName)

// モデルの読み込み
ZenzEngine.initModel(modelFile.absolutePath)

// zenz を使用した推論
ZenzEngine.generateWithContextAndConditions(
                        profile = profile,
                        topic = topic,
                        style = style,
                        preference = preference,
                        leftContext = leftContext,
                        input = katakana
                    )

最後に

今回紹介した手順は、あくまで「Sumire に zenz を組み込んだときの、ひとつの実装例」です。実際には、端末性能やアプリの設計方針、UI/UX の要件によって「どこまでモデルを使うか」「どのタイミングで推論するか」は変わってくるはずです。この記事が、みなさん自身の IME 開発やローカル LLM 活用の出発点・参考例になればうれしいです。

また、素晴らしいモデルと実装例を公開してくださっている
Miwa Keita 氏、Takahashi Naoki 氏、azooKey / Zenzai プロジェクト、そして llama.cpp をはじめとする OSS コミュニティの皆さまに、深く感謝します。これらの成果があってはじめて、Sumire のローカルかな漢字変換も実現できました。

本記事で使用したデモアプリは GitHub で公開しています。
実際にビルドして動かしたり、自分のプロジェクト向けにコードを読み替えたりして、自由に活用してください。

ここまで読んでいただき、ありがとうございました。もし記事や Sumire を通してフィードバックや質問などあれば、X(@KazumaN1172)などで気軽に教えてもらえると、とても励みになります。

zenz_demo_.png

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?