SLM の出力空間制約による狭義自動化最適化手法を提案
本文の状態
日本語全文を表示中
詳細モードで約11分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
KDnuggets
本記事は、小規模言語モデル(SLM)を用いた狭義自動化タスクにおいて、生成テキストの解析ではなく出力空間を制約する手法がコストと精度の両面で有効であることを示し、具体的な実装例を提供している。
Continue in AI NEW LAB
このニュースを、実務の判断につなげる
AI NEW LABで、試したことや先に確認したい条件を共有できます。まずはログインなしで読めます。
AI NEW LABで論点を見るAI深層分析を開く2026年8月13日 21:34
AI深層分析
キーポイント
SLM の適したユースケース
サポートチケットのルーティングやデータ抽出など、入力と出力が制約された高頻度タスクには、LLM よりも低コストで高速な SLM が最適である。
従来の非効率なアプローチの問題
大規模モデルの習慣として、自由生成されたテキストを正規表現で後処理する手法や逐次呼び出しは、SLM の低速化とエラー率の上昇を招くボトルネックとなる。
出力空間制約による最適化
モデルに自由な生成を求めず、事前に定義された固定された出力セット(例:billing, technical, account)のみを選択させる手法が、精度と速度の向上に寄与する。
ベンチマーク環境と実装
Qwen2.5-0.5B-Instructモデルを用いた具体的な評価環境(M2 Macbook Air)と、必要なライブラリのインストール手順が提示されている。
生成からスコアリングへの転換
トークンを逐次生成する代わりに、1回の順方向パスで候補ラベルのトーンIDに制限してスコアリングを行うことで、構造的な誤りを防ぎつつ信頼できる確信度スコアを得られる。
重要な引用
Tasks that fall into this category include properly routing a support ticket, extracting a field from a form, tagging a document, and flagging a record for human review.
They write long conversational prompts and let the model generate free-form text before hunting through it with regular expressions.
loose output handling turns directly into measurable error rates.
The fix is to stop generating and start scoring. Run one forward pass, read the model's next-token distribution, and restrict your decision to the token IDs of your candidate labels.
編集コメントを表示
編集コメント
本記事は、大規模モデルへの過度な依存を見直し、特定のタスクに特化した小規模モデルの活用を促す実用的な視点を提供している。特に出力空間の制約という技術的アプローチは、現場での導入障壁を下げる重要な知見と言える。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。

応用 AI における注目は往々にして最先端の推論能力に集まりがちですが、産業現場で実際に必要とされる業務の多くは、それほど華やかではありません。それが「狭義の自動化(narrow automation)」です。
このカテゴリに属するタスクには、サポートチケットの適切な振り分け、フォームからの特定フィールドの抽出、ドキュメントへのタグ付け、人間によるレビューが必要なレコードのフラグ立てなどが含まれます。これらに共通するのは、入力範囲が限定されていること、出力空間が決まっていること、そして膨大な呼び出し量が発生することです。
まさにこれらのタスクこそが、小型言語モデル(SLM)に適しています。1 つの GPU に収まり、あるいは CPU 駆動でも動作し、ミリ秒単位で回答を返せるモデルは、アイテムあたりのコストが数千倍にもなる大規模言語モデル(LLM)への API 呼び出しよりも、正しいエンジニアリング選択となるケースが多々あります。
問題は、チームが最先端モデルの運用習慣をそのまま小型モデル(SLM)にも適用してしまう点にあります。長い会話形式のプロンプトを作成し、モデルに自由なテキスト生成を行わせた後、正規表現でその結果を検索するといった手法です。また、Python のループ内で一度に 1 回だけモデルを呼び出す運用も一般的です。
これらの非効率性は、ローカル環境の SLM では特に顕著になります。フォワードパス(推論)が 10 ミリ秒しかかからない場合、その周囲に付随する処理すべてがボトルネックとなり、出力の扱い方が緩いことはそのまま測定可能なエラー率として現れます。
本記事は、SLM 向けの狭義自動化最適化に関するシリーズの第 1 弾です。ここでは特に有用な手法の一つ、「生成されたテキストを解析するのではなく、出力空間を制約する」アプローチについて解説します。
公平な比較を行うため、以下のベンチマークではすべて Qwen2.5-0.5B-Instruct を float16 で使用し、Hugging Face Transformers 経由で、RAM 24GB と 16 コア Neural Engine を搭載した M2 Macbook Air 上で実行しています。
まず、Python 環境を設定して必要なライブラリをインストールしてください:
pip install torch transformers accelerate分類タスクには固定された回答セットが存在します。例えば、チケットを billing(請求)、technical(技術サポート)、account(アカウント)のいずれかに振り分ける場合、有効な出力はこれら 3 つだけで他にはありません。
しかし、一般的なパターンでは、モデルに「答えを書け」と指示し、数トークンを生成させた後、その結果文字列の中から認識可能な要素を検索しようとしてしまいます。
このアプローチは二つの点で同時に失敗します。第一に、処理が遅いことです。generate() は出力トークンごとに逐次的な順方向パスを実行するため、8 トークンを生成するには、直接回答を要求する際の約 8 倍の計算リソースが必要になります。第二に、信頼性が低いことです。小規模モデルは「Sure! This looks like a billing issue.」や「Billing/Account」、あるいは定義していないカテゴリなど、不適切な回答を平然と返す可能性があります。これらの応答すべてに対してフォールバックルールまたは再試行が必要であり、各フォールバックルールがエラーの蓄積につながる場所となります。
解決策は、生成を停止してスコアリングを開始することです。順方向パスを一度実行し、モデルの次トークン分布を読み取り、意思決定を候補ラベルのトークン ID に制限します。これにより構造的な誤りを防ぐことができ、付随的に信頼性の高いスコアも得られます。
# 自由テキストのパース
ここでは、自由テキストを生成してから後からパースする素朴なバージョンを紹介します。これをファイルに保存し、コマンドラインから実行してください。
import os
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
torch.set_num_threads(os.cpu_count() or 1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)
model.eval()
# our toy data to classify (600 records)
LABELS = ["billing", "technical", "account"]
tickets = [
"My card was charged twice for the same invoice.",
"The mobile app crashes whenever I open the settings page.",
"I need to change the email address on my profile.",
] * 200
def build_prompt(ticket):
messages = [
{
"role": "system",
"content": "You classify support tickets. Answer with exactly one of: billing, technical, account.",
},
{"role": "user", "content": f"Ticket: {ticket}\nCategory:"},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# nothing constrains the output here, so we let the model write a short answer and search it for a label (tokens++)
# each new token costs its own forward pass, and one ticket per call means no batching to amortize that (time++)
prompts = [build_prompt(t) for t in tickets]
predictions = []
# time inference
start = time.time()
for n, prompt in enumerate(prompts, start=1):
# this loop runs for minutes on CPU, so report progress rather than sitting silent
if n % 50 == 0:
rate = (time.time() - start) / n
print(f" {n}/{len(prompts)} tickets ({rate:.2f}s each)", flush=True)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
output = model.generate(
**inputs,
max_new_tokens=8,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
# generate() returns prompt + continuation, so slice the prompt off before decoding
generated = output[0, inputs["input_ids"].shape[1] :]
text = tokenizer.decode(generated, skip_special_tokens=True).strip().lower()
# substring match against the label list
predictions.append(next((label for label in LABELS if label in text), "UNPARSED"))
duration = time.time() - start
# output task metrics
print(f"Free-form generation took: {duration:.2f} seconds")
print(f"Unparseable outputs: {predictions.count('UNPARSED')} / {len(predictions)}")
# sample of inference output
for ticket, label in zip(tickets[-3:], predictions[-3:], strict=True):
print(f"{ticket} -> {label}")出力:
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 290/290 [00:01<00:00, 263.89it/s]
50/600 tickets (0.19s each)
100/600 tickets (0.19s each)
150/600 tickets (0.19s each)
200/600 tickets (0.19s each)
250/600 tickets (0.19s each)
300/600 tickets (0.19s each)
350/600 tickets (0.19s each)
400/600 tickets (0.19s each)
450/600 tickets (0.19s each)
500/600 tickets (0.19s each)
550/600 tickets (0.19s each)
600/600 tickets (0.19s each)
Free-form generation took: 134.01 seconds
Unparseable outputs: 0 / 600
My card was charged twice for the same invoice. -> billing
The mobile app crashes whenever I open the settings page. -> technical
I need to change the email address on my profile. -> technicalバッチ全体がパースャーが処理可能な形状で返されましたが、実行時間が 134 秒であった点には注意が必要です。
# 出力空間の制約
次に、単一の順方向パスから直接ラベルセットをスコアリングする、制約付きバージョンを試してみましょう:
import os
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
torch.set_num_threads(os.cpu_count() or 1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float32)
model.eval()
# our toy data to classify (600 records)
LABELS = ["billing", "technical", "account"]
tickets = [
"My card was charged twice for the same invoice.",
"The mobile app crashes whenever I open the settings page.",
"I need to change the email address on my profile.",
] * 200
def build_prompt(ticket):
messages = [
{
"role": "system",
"content": "You classify support tickets. Answer with exactly one of: billing, technical, account.",
},
{"role": "user", "content": f"Ticket: {ticket}\nCategory:"},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# prompt ends with "<|im_start|>assistant\n", so the model's next token starts the label
# comparing the logits of each label's FIRST token is enough to pick a winner, provided those first tokens are distinct
label_first_ids = [tokenizer.encode(label, add_special_tokens=False)[0] for label in LABELS]
assert len(set(label_first_ids)) == len(LABELS), (
"Labels share a first token; score full label sequences instead (see notes)."
)
label_first_ids = torch.tensor(label_first_ids, device=model.device)
# one forward pass per ticket, no generation loop: the decision contained entirely in the next-token logits
prompts = [build_prompt(t) for t in tickets]
predictions = []
confidences = []
# time inference
start = time.time()
for prompt in prompts:
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
logits = model(**inputs).logits[0, -1, :]
# softmax over just the label logits, so the probabilities sum to 1 across the candidates
probs = torch.softmax(logits[label_first_ids].float(), dim=-1)
best = int(probs.argmax())
predictions.append(LABELS[best])
confidences.append(float(probs[best]))
duration = time.time() - start
# output task metrics
print(f"Constrained scoring took: {duration:.2f} seconds")
print(f"Unparseable outputs: {predictions.count('UNPARSED')} / {len(predictions)}")
# sample of inference output
for ticket, label, confidence in zip(
tickets[-3:], predictions[-3:], confidences[-3:], strict=True
):
print(f"{ticket} -> {label} (confidence {confidence:.3f})")出力:
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 290/290 [00:01<00:00, 285.60it/s]
Constrained scoring took: 94.51 seconds
Unparseable outputs: 0 / 600
My card was charged twice for the same invoice. -> billing (confidence 0.793)
The mobile app crashes whenever I open the settings page. -> technical (confidence 0.798)
I need to change the email address on my profile. -> technical (confidence 0.673)この手法は、約 30% の時間短縮を実現し、失敗の可能性も排除しました。さらに詳しいテストでは、スケールしてもこの時間比率が維持されること、またチケットのテキストをいじることで、単純なバージョンでは検出されなかった不具合が、2 つ目のバージョンでは露呈することが示されています。
上記のコードに関する補足説明です。
logits[0, -1, :]を読み取ることで、モデルが次のトークンに対して持つ正規化されていない分布を取得できます。回答が 3 つの既知の文字列のいずれかである場合、generate()がその後に実行する処理は不要となります。
「label_first_ids」ベクトルにインデックスを付け、argmax を実行することで、未知語(OOV)の回答が構造的に不可能になります。これにより、モデルはフォーマットに関する創造性を発揮できなくなります。そのため、解析不能なケースが 0 / 600 とゼロになるのは偶然ではなく、設計上の必然です。
制限されたロジットに対するソフトマックス計算は、有用な信頼度チェックとして機能します。実務的には、任意の閾値(例:初期値として 0.6)を下回る結果を人間のキューへ転送し、低信頼度のラベルがワークフローの下流に流れ出ることを防ぐことができます。
トークン化には注意が必要です。多くのバイトレベルのBPEトークナイザーは、" billing" と "billing" を別々のトークンとして扱います。そのため、プロンプト後にモデルが実際に出力するバリアントをエンコードしてください。
チャットテンプレートは"assistant\n"で終わるため、次のトークンは改行の直後に続き、先頭にスペースが含まれません。そのため、encode(label)を使用し、encode(" " + label)とはなりません。これを混同するとスクリプト自体は正常に動作しますが、モデルが決して生成しないトークンを3つ評価対象として含めてしまうことになります。
- 「refund_request」と「refund_status」のように、最初のトークンが共通するラベル同士が存在すると、アサーションが発火します。 (原文の技術表記:
"refund_request"、"refund_status")
ラベルを A、B、C のように単一の固有トークンにリネームするか、
プロンプト内の凡例を使用するか、最初のトークンではなくラベルシーケンス全体をスコアリングする。
まとめ
これは狭義の自動化向けに SLM(小型言語モデル)を最適化する初めての試みであり、今回採用した手法は「制約付きスコアリング」です。この手法では、自由形式の生成と文字列解析に代わり、有効なラベルセットに限定された単一の順方向パスを使用します。これにより、構造的に誤った出力が不可能になるだけでなく、エッジケースを人間へ転送するための信頼度スコアも提供できます。
今日使用した05億パラメータのモデルのような小型言語モデル(SLM)は、周囲のコードがそれを汎用的なチャットボットとして扱わなくなり、ChatGPT のように対話するのをやめれば、狭い領域の自動化における実用的な生産選択肢となります。出力契約を強制することで、小型モデルは妥協点ではなくなり、明白な最適解へと変わります。
原文を表示

**
So much of the attention in applied AI goes to frontier-scale reasoning; however, a large share of the actual production workload necessity in industry is far less glamorous: narrow automation. Tasks that fall into this category include properly routing a support ticket, extracting a field from a form, tagging a document, and flagging a record for human review. These tasks all have common characteristics: constrained input, a fixed output space, and enormous call volume. They are also exactly the types of tasks that are well-suited to small language models (SLMs). A model that fits comfortably on one GPU, or is even CPU-bound, and is able to return an answer in milliseconds can often be the correct engineering choice over an API call to a large language model (LLM) that could cost a thousand times more per item.
The trouble is that teams tend to carry frontier-model habits over to small models. They write long conversational prompts and let the model generate free-form text before hunting through it with regular expressions. They call the model once at a time from inside a Python loop. Against a local SLM, these types of inefficiencies are accentuated: when a single forward pass takes ten milliseconds, everything you wrap around that forward pass becomes the bottleneck; loose output handling turns directly into measurable error rates.
This article will kick off a series on narrow automation optimization for SLMs, and as the first entry will cover one of the more most useful techniques for doing so: constraining the output space instead of parsing generated text.
To set a level playing field, all benchmarks below use Qwen2.5-0.5B-Instruct in float16 through Hugging Face Transformers**, running on an M2 Macbook Air with 24GB RAM and a 16-core Neural Engine.
First, setup a Python environment and install your requirements:
pip install torch transformers accelerate**
A classification task has a fixed answer set. If you are routing tickets into billing, technical, or account, there are exactly three valid outputs and no others. Yet the standard pattern is to ask the model to write the answer, generate a handful of tokens, and then search the resulting string for something recognizable.
This fails in two ways simultaneously. First, it is slow: generate() runs one sequential forward pass per output token, so asking for eight tokens costs roughly eight times the compute of asking for the answer directly. Second, it is unreliable: a small model will happily reply with "Sure! This looks like a billing issue.", or "Billing/Account", or a category you never defined. Every one of those responses requires either a fallback rule or a retry, and every fallback rule is a place for error accumulation.
The fix is to stop generating and start scoring. Run one forward pass, read the model's next-token distribution, and restrict your decision to the token IDs of your candidate labels. The answer becomes impossible to get wrong structurally, and you get a calibrated confidence score as a byproduct.
# Parsing Free Text
Here is the naive version, generating free text and parsing it after the fact. Save it to file and run it from the command line.
import os
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
torch.set_num_threads(os.cpu_count() or 1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)
model.eval()
# our toy data to classify (600 records)
LABELS = ["billing", "technical", "account"]
tickets = [
"My card was charged twice for the same invoice.",
"The mobile app crashes whenever I open the settings page.",
"I need to change the email address on my profile.",
] * 200
def build_prompt(ticket):
messages = [
{
"role": "system",
"content": "You classify support tickets. Answer with exactly one of: billing, technical, account.",
},
{"role": "user", "content": f"Ticket: {ticket}\nCategory:"},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# nothing constrains the output here, so we let the model write a short answer and search it for a label (tokens++)
# each new token costs its own forward pass, and one ticket per call means no batching to amortize that (time++)
prompts = [build_prompt(t) for t in tickets]
predictions = []
# time inference
start = time.time()
for n, prompt in enumerate(prompts, start=1):
# this loop runs for minutes on CPU, so report progress rather than sitting silent
if n % 50 == 0:
rate = (time.time() - start) / n
print(f" {n}/{len(prompts)} tickets ({rate:.2f}s each)", flush=True)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
output = model.generate(
**inputs,
max_new_tokens=8,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
# generate() returns prompt + continuation, so slice the prompt off before decoding
generated = output[0, inputs["input_ids"].shape[1] :]
text = tokenizer.decode(generated, skip_special_tokens=True).strip().lower()
# substring match against the label list
predictions.append(next((label for label in LABELS if label in text), "UNPARSED"))
duration = time.time() - start
# output task metrics
print(f"Free-form generation took: {duration:.2f} seconds")
print(f"Unparseable outputs: {predictions.count('UNPARSED')} / {len(predictions)}")
# sample of inference output
for ticket, label in zip(tickets[-3:], predictions[-3:], strict=True):
print(f"{ticket} -> {label}")Output:
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 290/290 [00:01<00:00, 263.89it/s]
50/600 tickets (0.19s each)
100/600 tickets (0.19s each)
150/600 tickets (0.19s each)
200/600 tickets (0.19s each)
250/600 tickets (0.19s each)
300/600 tickets (0.19s each)
350/600 tickets (0.19s each)
400/600 tickets (0.19s each)
450/600 tickets (0.19s each)
500/600 tickets (0.19s each)
550/600 tickets (0.19s each)
600/600 tickets (0.19s each)
Free-form generation took: 134.01 seconds
Unparseable outputs: 0 / 600
My card was charged twice for the same invoice. -> billing
The mobile app crashes whenever I open the settings page. -> technical
I need to change the email address on my profile. -> technicalWhile the the entirety of the batch came back in a shape the parser could handle, we will note the 134 second execution time.
# Constraining the Output Space
Now let's try a constrained version, which scores the label set directly from a single forward pass:
import os
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
torch.set_num_threads(os.cpu_count() or 1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float32)
model.eval()
# our toy data to classify (600 records)
LABELS = ["billing", "technical", "account"]
tickets = [
"My card was charged twice for the same invoice.",
"The mobile app crashes whenever I open the settings page.",
"I need to change the email address on my profile.",
] * 200
def build_prompt(ticket):
messages = [
{
"role": "system",
"content": "You classify support tickets. Answer with exactly one of: billing, technical, account.",
},
{"role": "user", "content": f"Ticket: {ticket}\nCategory:"},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# prompt ends with "<|im_start|>assistant\n", so the model's next token starts the label
# comparing the logits of each label's FIRST token is enough to pick a winner, provided those first tokens are distinct
label_first_ids = [tokenizer.encode(label, add_special_tokens=False)[0] for label in LABELS]
assert len(set(label_first_ids)) == len(LABELS), (
"Labels share a first token; score full label sequences instead (see notes)."
)
label_first_ids = torch.tensor(label_first_ids, device=model.device)
# one forward pass per ticket, no generation loop: the decision contained entirely in the next-token logits
prompts = [build_prompt(t) for t in tickets]
predictions = []
confidences = []
# time inference
start = time.time()
for prompt in prompts:
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
logits = model(**inputs).logits[0, -1, :]
# softmax over just the label logits, so the probabilities sum to 1 across the candidates
probs = torch.softmax(logits[label_first_ids].float(), dim=-1)
best = int(probs.argmax())
predictions.append(LABELS[best])
confidences.append(float(probs[best]))
duration = time.time() - start
# output task metrics
print(f"Constrained scoring took: {duration:.2f} seconds")
print(f"Unparseable outputs: {predictions.count('UNPARSED')} / {len(predictions)}")
# sample of inference output
for ticket, label, confidence in zip(
tickets[-3:], predictions[-3:], confidences[-3:], strict=True
):
print(f"{ticket} -> {label} (confidence {confidence:.3f})")Output:
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 290/290 [00:01<00:00, 285.60it/s]
Constrained scoring took: 94.51 seconds
Unparseable outputs: 0 / 600
My card was charged twice for the same invoice. -> billing (confidence 0.793)
The mobile app crashes whenever I open the settings page. -> technical (confidence 0.798)
I need to change the email address on my profile. -> technical (confidence 0.673)This took about 30% less time, with the potential of failure eliminated. Further tests show the time ratio holds at scale, and also that messing with the ticket text can expose the failures in the naive version that are caught with the second version. I'll leave testing this to the reader.
Some further explanation of the code above:
- Reading logits[0, -1, :] gives the model's unnormalized distribution over the next token. Everything generate() would do afterward is unnecessary when the answer is one of three known strings.
- Indexing that vector at label_first_ids and taking argmax makes an out-of-vocabulary answer structurally impossible. The model is no longer allowed to be creative about formatting, which is why the unparseable count is 0 / 600 by construction rather than by luck.
- The softmax over the restricted logits is a useful confidence check. Practically speaking, you could route anything below a threshold you choose — say, 0.6 as a starting point — to a human queue rather than allowing a low-confidence label to flow downstream in the workflow.
- Mind the tokenization. Most byte-level BPE tokenizers treat " billing" and "billing" as distinct tokens, so encode the variant the model would actually emit after your prompt. The chat template ends with "<|im_start|>assistant\n", so the next token follows a newline and carries no leading space, hence encode(label) rather than encode(" " + label). Mix this up and the script runs fine; however, you end up scoring three tokens the model was never going to emit.
- If two labels share a first token ("refund_request" and "refund_status", for instance), the assertion fires. Either rename the labels to single distinct tokens (such as A, B, C) with a legend in the prompt, or score the full label sequences instead of the first token.
# Wrapping Up
This has been our first attempt at optimizing SLMs for narrow automation, and our target technique this time was constrained scoring**. This technique replaces free-form generation and string parsing with a single forward pass restricted to the valid label set. By implementing it, we can make malformed output structurally impossible while handing you a confidence score for routing edge cases to humans.
A small language model, such as the 0.5B parameter model we used today, becomes a practical production choice for narrow automation once the code around it stops treating it like a generic chatbot, and stops interacting with it like it would ChatGPT. With an enforced output contract, the small model stops being a compromise and starts being the obvious answer.
Matthew Mayo (@mattmayo13) holds a master's degree in computer science and a graduate diploma in data mining. As managing editor of KDnuggets & Statology, and contributing editor at Machine Learning Mastery, Matthew aims to make complex data science concepts accessible. His professional interests include natural language processing, language models, machine learning algorithms, and exploring emerging AI. He is driven by a mission to democratize knowledge in the data science community. Matthew has been coding since he was 6 years old.
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み