1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tinker を使ってローカルから LLM を LoRA 学習してみた (ドキュメント解説・最小コード)

Last updated at Posted at 2025-11-15

はじめに

10月の頭に突如として現れたTinkerというサービス。
なにやら色々な著名な方が「学習や研究にとても良い」と騒いでいたので急いでWaitlistに登録。

久しぶりに思い出してメールを除いたら偶然にも丁度Wait期間が終わったので色々と触ってまとめていきます。

TinkerのXの投稿:

Tinkerとは?

以下、重要箇所の抜粋と要約です。

TinkerはLLMを柔軟にFinetuningするためのAPIです。(あえて簡単にとは言いません。)
Tinkerを使用することによってユーザーは面倒な分散トレーニング部分(GPUの用意など)を行う必要が無くなり、データセットの用意や訓練ロジック部分などの本質的な部分にフォーカスすることができます。

TinkerはFinetuningを簡単にする魔法のブラックボックスではありません。
ユーザーを分散トレーニングの複雑さから保護し、制御性を維持する明確な抽象化です。

特徴

  • TinkerではMoEを含むQwenやLlamaシリーズなどのいくつかのオープンウェイトモデルをFinetuning可能です。
  • Tinkerは現在LoRAでの実装を行っています。Full Finetuningについては今後サポート予定です。
  • Tinkerで訓練したモデルの重みをダウンロードすることができます。

使用可能モデルと料金

Wait期間が終わりコンソールにアクセスすると次の様な料金表を確認できます。
Qwen、Llamaの他にもDeepseekやGPT-ossなどが使える様です。

料金は100万トークン当たりの料金です。(安い!!)

Model Prefill Sample Train
Qwen/Owen3-4B-Instruct-2507 $0.07 $0.22 $0.22
Qwen/Owen3-8B $0.13 $0.40 $0.40
Qwen/Owen3-30B-A3B $0.12 $0.30 $0.36
Qwen/Owen3-32B $0.49 $1.47 $1.47
Qwen/Owen3-235B-Instruct-2507 $0.68 $1.70 $2.04
meta-llama/Llama-3.2-1B $0.03 $0.09 $0.09
meta-llama/Llama-3.2-3B $0.06 $0.18 $0.18
meta-llama/Llama-3.1-8B $0.13 $0.40 $0.40
meta-llama/Llama-3.1-70B $1.05 $3.16 $3.16
DeepSeek-V3.1 $1.13 $2.81 $3.38
GPT-OSS-120B $0.18 $0.44 $0.52
GPT-OSS-20B $0.12 $0.30 $0.36

触ってみる

インストールはpip経由で行えます。

pip install tinker

環境変数TINKER_API_KEYにAPI KEYをセットします。
本記事では基本的にpython-dotenvを使って環境変数を読み込みます。

以降からはドキュメント内の教師あり学習のデモを動かして確認していきます。

利用可能なモデルを確認する。

TinkerのServiceClientをインスタンス化し、service_client.get_server_capabilities().supported_modelsから取得可能です。

import tinker
from dotenv import load_dotenv
load_dotenv()

service_client = tinker.ServiceClient()
print("Available models:")
for item in service_client.get_server_capabilities().supported_models:
    print("- " + item.model_name)

出力

Available models:
- deepseek-ai/DeepSeek-V3.1
- deepseek-ai/DeepSeek-V3.1-Base
- meta-llama/Llama-3.1-70B
- meta-llama/Llama-3.1-8B
- meta-llama/Llama-3.1-8B-Instruct
- meta-llama/Llama-3.2-1B
- meta-llama/Llama-3.2-3B
- meta-llama/Llama-3.3-70B-Instruct
- Qwen/Qwen3-235B-A22B-Instruct-2507
- Qwen/Qwen3-30B-A3B
- Qwen/Qwen3-30B-A3B-Base
- Qwen/Qwen3-30B-A3B-Instruct-2507
- Qwen/Qwen3-32B
- Qwen/Qwen3-4B-Instruct-2507
- Qwen/Qwen3-8B
- Qwen/Qwen3-8B-Base
- openai/gpt-oss-120b
- openai/gpt-oss-20b

訓練用のClientをインスタンス化します。
ここではbaseモデルを使用します。

そもそもbaseモデルとは何ですかと言ったような内容は本記事では説明しませんが、基本的には事前学習のみを終えたモデル、なので別途SFTなどの微調整を施していない純粋なモデルだと思ってください。

base_model = "Qwen/Qwen3-30B-A3B-Base"
training_client = service_client.create_lora_training_client(
    base_model=base_model
)

今回のデモでは英単語をPig Latinに翻訳するという学習を行います。
Pig Latinのルールは次のようになります。


Pig Latinルール

  1. 英単語の1文字目を確認。
  2. 母音か子音かによって英単語の後ろに文字を付け足す。
    1. 母音なら末尾に「way」を付け足す。
    2. 子音なら子音を末尾に移動かつ「ay」を付け足す。

例:
america -> america-way
hello -> ello-hay

hello world -> ello-hay orld-way


次に、学習のために少量の教師ありデータセットサンプルを用意します。

# Create some training examples
examples = [
    {
        "input": "banana split",
        "output": "anana-bay plit-say"
    },
    {
        "input": "quantum physics",
        "output": "uantum-qay ysics-phay"
    },
    {
        "input": "donut shop",
        "output": "onut-day op-shay"
    },
    {
        "input": "pickle jar",
        "output": "ickle-pay ar-jay"
    },
    {
        "input": "space exploration",
        "output": "ace-spay exploration-way"
    },
    {
        "input": "rubber duck",
        "output": "ubber-ray uck-day"
    },
    {
        "input": "coding wizard",
        "output": "oding-cay izard-way"
    },
]

次にデータをTraining clientが期待する形式に変換する処理を行います。

# Convert examples into the format expected by the training client
from tinker import types
 
# Get the tokenizer from the training client
tokenizer = training_client.get_tokenizer()
 
def process_example(example: dict, tokenizer) -> types.Datum:
    # Format the input with Input/Output template
    # For most real use cases, you'll want to use a renderer / chat template,
    # (see later docs) but here, we'll keep it simple.
    prompt = f"English: {example['input']}\nPig Latin:"
 
    prompt_tokens = tokenizer.encode(prompt, add_special_tokens=True)
    prompt_weights = [0] * len(prompt_tokens)
    # Add a space before the output string, and finish with double newline
    completion_tokens = tokenizer.encode(f" {example['output']}\n\n", add_special_tokens=False)
    completion_weights = [1] * len(completion_tokens)
 
    tokens = prompt_tokens + completion_tokens
    weights = prompt_weights + completion_weights
 
    input_tokens = tokens[:-1]
    target_tokens = tokens[1:] # We're predicting the next token, so targets need to be shifted.
    weights = weights[1:]
 
    # A datum is a single training example for the loss function.
    # It has model_input, which is the input sequence that'll be passed into the LLM,
    # loss_fn_inputs, which is a dictionary of extra inputs used by the loss function.
    return types.Datum(
        model_input=types.ModelInput.from_ints(tokens=input_tokens),
        loss_fn_inputs=dict(weights=weights, target_tokens=target_tokens)
    )
 
processed_examples = [process_example(ex, tokenizer) for ex in examples]

コメントにも記載があるので詳しい話は不要だとは思いますが、データをInputとOutput(completion)部分に分ける処理をしています。
もう少し言うとユーザーの入力ではなく補完部分を学習させるための形式にしていますね。

次にサンプルとして中身を確認します。

# Visualize the first example for debugging purposes
datum0 = processed_examples[0]
print(f"{'Input':<20} {'Target':<20} {'Weight':<10}")
print("-" * 50)
for i, (inp, tgt, wgt) in enumerate(zip(datum0.model_input.to_ints(), datum0.loss_fn_inputs['target_tokens'].tolist(), datum0.loss_fn_inputs['weights'].tolist())):
    print(f"{repr(tokenizer.decode([inp])):<20} {repr(tokenizer.decode([tgt])):<20} {wgt:<10}")

ちなみにここまでをローカルで実行するとtransformersのimportエラーが発生しました。
該当箇所はtokenizer = training_client.get_tokenizer()で、この関数自体はtraining_clientをインスタンス化時に渡したmodel_idを基にtokenizerを取得するというメソッドな訳ですが、中身は次のようになっています。

def _get_tokenizer(model_id: types.ModelID, holder: InternalClientHolder) -> PreTrainedTokenizer:
    # call get_info on model_id
    from transformers.models.auto.tokenization_auto import AutoTokenizer

    async def _get_info_async():
        with holder.aclient(ClientConnectionPoolType.TRAIN) as client:
            request = types.GetInfoRequest(model_id=model_id)
            return await client.models.get_info(request=request)

    info = holder.run_coroutine_threadsafe(_get_info_async()).result()
    model_name = info.model_data.model_name
    assert model_name is not None, "This shouldn't happen: model_name is None"

    # Use tokenizer_id from get_info if available, otherwise fall back to heuristic logic
    tokenizer_id = info.model_data.tokenizer_id
    if tokenizer_id is None:
        # We generally adhere to the huggingface convention of "<org>/<model>" but
        # in some cases we'll deploy variants using the format
        # "<org>/<model>/<variant>". In that case, we want to load the tokenizer
        # using the huggingface convention.
        if model_name.startswith("meta-llama/Llama-3"):
            # Avoid gating of Llama 3 models:
            tokenizer_id = "baseten/Meta-Llama-3-tokenizer"
        elif model_name.count("/") == 2:
            org, model, _variant = model_name.split("/", 2)
            tokenizer_id = f"{org}/{model}"
        else:
            tokenizer_id = model_name

    return AutoTokenizer.from_pretrained(tokenizer_id, fast=True)

なのでここでimportエラーが発生した訳ですね。
transformersをインストールすれば解決します。

pip install transformers

ここで注意・認識しておかないといけない事としては、トークナイズはローカルで行われると言う点です。
メリット?としてTinker側で行わないのでクレジットを消費しなくてよくなります。
デメリットとしては大規模なデータだとそれなりに時間や環境が必要になることと、ダウンロード先に注意を払う必要があることです。

ちなみにデフォルトではuser/.cache/huggingface/この辺りに入っているはずです。

余談が長くなりましたが、結果は次のようになります。
ダウンロードされているのがログからでも分かるかと思います。(トークナイザーのみなのでそこまで大きくは無いです)

vocab.json: 2.78MB [00:00, 9.93MB/s]
merges.txt: 1.67MB [00:00, 23.3MB/s]
tokenizer.json: 7.03MB [00:00, 20.8MB/s]
Input                Target               Weight
--------------------------------------------------
'English'            ':'                  0.0
':'                  ' banana'            0.0
' banana'            ' split'             0.0
' split'             '\n'                 0.0
'\n'                 'P'                  0.0
'P'                  'ig'                 0.0
'ig'                 ' Latin'             0.0
' Latin'             ':'                  0.0
':'                  ' an'                1.0
' an'                'ana'                1.0
'ana'                '-b'                 1.0
'-b'                 'ay'                 1.0
'ay'                 ' pl'                1.0
' pl'                'it'                 1.0
'it'                 '-s'                 1.0
'-s'                 'ay'                 1.0
'ay'                 '\n\n'               1.0

データの準備が出来たので学習を実行します。
デモなので1つのバッチデータを6回使用しています。

import numpy as np
for _ in range(6):
    fwdbwd_future = training_client.forward_backward(processed_examples, "cross_entropy")
    optim_future = training_client.optim_step(types.AdamParams(learning_rate=1e-4))
 
    # Wait for the results
    fwdbwd_result = fwdbwd_future.result()
    optim_result = optim_future.result()
 
    # fwdbwd_result contains the logprobs of all the tokens we put in. Now we can compute the weighted
    # average log loss per token.
    logprobs = np.concatenate([output['logprobs'].tolist() for output in fwdbwd_result.loss_fn_outputs])
    weights = np.concatenate([example.loss_fn_inputs['weights'].tolist() for example in processed_examples])
    print(f"Loss per token: {-np.dot(logprobs, weights) / weights.sum():.4f}")

ここでの重要な点として、forward_backwardoptim_step関数を実行した後にresult()を実行します。
forward_backwardoptim_step関数はサーバー側に各JOBを送るだけの非同期関数であり、result()が実際に完了まで待機するAPIになります。

なので言ってしまえば、forward_backward -> result() -> optim_step -> result()でも良いと言えば良いのですが遅くなります。

最後の部分はTokenごとのLossを確認しています。

この時点ではまだ実行しないでください。
後続に記載するsaveメソッドを使用せずに終了してしまうと無駄な料金がかかってしまいます。

最後に学習したモデルでのサンプリングを行います。
ここでは「coffee break」というフレーズをPig Latinに翻訳します。

# First, create a sampling client. We need to transfer weights
sampling_client = training_client.save_weights_and_get_sampling_client(name='pig-latin-model')
 
# Now, we can sample from the model.
prompt=types.ModelInput.from_ints(tokenizer.encode("English: coffee break\nPig Latin:"))
params = types.SamplingParams(max_tokens=20, temperature=0.0, stop=["\n"]) # Greedy sampling
future = sampling_client.sample(prompt=prompt, sampling_params=params, num_samples=8)
result = future.result()
print("Responses:")
for i, seq in enumerate(result.sequences):
    print(f"{i}: {repr(tokenizer.decode(seq.tokens))}")

save_weights_and_get_sampling_client()は文字通り重みを保存して、サンプリング用のClientを初期化する関数です。
保存するのでname引数には任意の名前を記載します。

promptには学習データ同様の形式のユーザーインプットを作成し、temperatureを0.0でGreedy方式でsamplingします。  

sample数は8を設定し、最後に結果の確認をするコードが含まれます。

実行結果はこんな感じです。

Loss per token: 1.0631
Loss per token: 0.8692
Loss per token: 0.5858
Loss per token: 0.3660
Loss per token: 0.1924
Loss per token: 0.1009
Responses:
0: ' offecay eakbray\n\n'
1: ' offer-cay eak-bray\n\n'
2: ' offeecay eakbray\n\n'
3: ' offy-pay eak-bra-pay\n\n'
4: ' off-ay eak-bay\n\n'
5: ' offeecay eak-bray\n\n'
6: ' offee-cay eak-brcay\n\n'

結果の正しさは置いといて、変換を試みようとしているので学習は出来ていそうですね。

最後に

今回はラップトップPCのローカル環境でもLLMのFinetuningが行えるようになるAPIであるTinkerについて触ってみました!

Tinkerは従量課金制のAPIなので、ユーザー側は複雑な分散GPU環境の整備が不要ですし、何よりも隙間時間に試すことができます。
(自前でGPUを複数台用意するのは大変ですし、30分くらい動かしたいだけなのにクラウド経由で色々やるのも気が引けます。)

今回試したのはあくまでもサンプル例であり、TinkerにはLoRAパラメーターの調整や強化学習、多段階トレーニングなど、できることはもっと沢山あります。

キャッチアップついでに色々触って行こうと思います!

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?