This is a full fine-tune of
google/functiongemma-270m-it that turns an
English banking request into one of ten structured support tool calls. It is a
learning experiment, not a production banking system.
BANKING77 is normally a classification dataset with text, integer label,
and human-readable label_text fields. This experiment does not add a
classification head. It converts label_text into the expected assistant tool
call and fine-tunes FunctionGemma to generate that native structure.
{
"text": "My card is gone. I think it was stolen.",
"label_text": "lost_or_stolen_card"
}
becomes a target call to handle_lost_or_stolen_card.
Every tool receives the original customer message. One complete schema is:
{
"type": "function",
"function": {
"name": "handle_lost_or_stolen_card",
"description": "Handle a card reported lost or stolen.",
"parameters": {
"type": "object",
"properties": {
"customer_message": {
"type": "string",
"description": "The original customer support message."
}
},
"required": [
"customer_message"
]
},
"return": {
"type": "string"
}
}
}
| Function | Intended request |
|---|---|
handle_card_arrival | Handle questions about when a newly ordered card will arrive. |
handle_card_not_working | Handle reports that a physical bank card does not work. |
handle_cash_withdrawal_not_recognised | Handle an unrecognized cash withdrawal. |
handle_change_pin | Handle requests to change a card PIN. |
handle_compromised_card | Handle reports that card details may be compromised. |
handle_lost_or_stolen_card | Handle a card reported lost or stolen. |
handle_pending_card_payment | Handle a card payment that is still pending. |
handle_terminate_account | Handle requests to close a bank account. |
handle_transfer_not_received_by_recipient | Handle a transfer the recipient has not received. |
handle_verify_my_identity | Handle questions about completing identity verification. |
import re
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "lxyuan/FunctionGemma-270M-banking77-router"
TOOL_DESCRIPTIONS = {
"handle_card_arrival": "Handle questions about when a newly ordered card will arrive.",
"handle_card_not_working": "Handle reports that a physical bank card does not work.",
"handle_cash_withdrawal_not_recognised": "Handle an unrecognized cash withdrawal.",
"handle_change_pin": "Handle requests to change a card PIN.",
"handle_compromised_card": "Handle reports that card details may be compromised.",
"handle_lost_or_stolen_card": "Handle a card reported lost or stolen.",
"handle_pending_card_payment": "Handle a card payment that is still pending.",
"handle_terminate_account": "Handle requests to close a bank account.",
"handle_transfer_not_received_by_recipient": "Handle a transfer the recipient has not received.",
"handle_verify_my_identity": "Handle questions about completing identity verification."
}
def make_tool(name: str, description: str) -> dict:
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": {
"customer_message": {"type": "string"},
},
"required": ["customer_message"],
},
"return": {"type": "string"},
},
}
tools = [make_tool(name, description) for name, description in TOOL_DESCRIPTIONS.items()]
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
message = "My card was stolen last night"
inputs = tokenizer.apply_chat_template(
[
{
"role": "developer",
"content": "You route customer requests by calling exactly one banking support tool.",
},
{"role": "user", "content": message},
],
tools=tools,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
).to(model.device)
output = model.generate(**inputs, max_new_tokens=64, do_sample=False)
generated = tokenizer.decode(
output[0, inputs["input_ids"].shape[1] :],
skip_special_tokens=False,
)
match = re.search(
r"<start_function_call>call:([a-z0-9_]+).*?<end_function_call>",
generated,
re.DOTALL,
)
if match is None:
raise ValueError(f"No complete function call: {generated!r}")
print({"tool": match.group(1), "raw_call": match.group(0)})
The model selects a call; it does not execute the tool. Validate arguments and dispatch through an explicit allow-listed handler map.
| Customer input | First generated tool |
|---|---|
I am sick of this damn company and want to close out my account. | handle_terminate_account |
My card doesn't work. | handle_card_not_working |
My card is gone I think it was stolen | handle_lost_or_stolen_card |
There is a withdrawal that isn't mind in the app. | handle_cash_withdrawal_not_recognised |
When will I get my card? | handle_card_arrival |
Can i change my PIN at the ATM? | handle_change_pin |
Input: I am sick of this damn company and want to close out my account.
Output: <start_function_call>call:handle_terminate_account{customer_message:<escape>I am sick of this damn company and want to close out my account.<escape>}<end_function_call>
This run uses TRL SFTTrainer with loss_type="chunked_nll". This is the
standard causal-language-model next-token negative log-likelihood, or
cross-entropy, computed in memory-saving chunks:
loss = mean(-log P(correct next token | previous tokens))
assistant_only_loss=False, and this is a conversational language-modeling
dataset rather than a prompt-completion dataset. Therefore every non-padding
token in the rendered developer prompt, tool declarations, user message, and
assistant call contributes. Padding labels use -100 and are ignored. Exact
generated tool accuracy is a separate application metric and is not the
differentiable loss. Chunking does not alter this token selection; the label
mask controls which tokens contribute, while loss_type controls how the same
calculation is held in memory. See the
TRL 1.12 SFT documentation.
| Metric | Value |
|---|---|
| Base-model exact first-tool accuracy | 51.00% |
| Fine-tuned exact first-tool accuracy | 97.00% |
| Generated evaluation examples | 100 |
| Final evaluation loss | 0.0492 |
| Mean training loss | 0.0959 |
Training loss can continue falling while validation loss rises because the model becomes more confident on repeated training rows without improving equally on unseen rows. Cross-entropy can increase from a few confidently wrong tokens even when average token accuracy changes little.
TensorBoard event files, trainer_state.json, and training_metrics.json are
included in this repository for inspecting the training curves and recorded
results.
| Setting | Value |
|---|---|
| Training examples | 800 |
| Evaluation examples | 200 |
| Epochs | 4 |
| Learning rate | 5e-05 |
| Effective batch size | 8 |
| Maximum sequence length | 1024 |
| Loss | chunked_nll, full rendered sequence except padding |
| Warmup | 0.1 steps in the reference run (effectively no warmup) |
| Seed | 42 |
Software:
datasets==5.0.1tensorboard==2.20.0torch==2.11.0+cu128transformers==5.16.1trl==1.12.0Load the saved FP32 checkpoint without forcing all weights to FP16. The verified FP32 Hub reload produced a valid function call, while forced pure FP16 on a T4 produced padding-only output. Training used FP16 autocast around FP32 master weights.
The BANKING77 mirror describes the dataset as CC BY 4.0. FunctionGemma weights remain subject to the Gemma terms.
9 commits
This is a full fine-tune of
google/functiongemma-270m-it that turns an
English banking request into one of ten structured support tool calls. It is a
learning experiment, not a production banking system.
BANKING77 is normally a classification dataset with text, integer label,
and human-readable label_text fields. This experiment does not add a
classification head. It converts label_text into the expected assistant tool
call and fine-tunes FunctionGemma to generate that native structure.
{
"text": "My card is gone. I think it was stolen.",
"label_text": "lost_or_stolen_card"
}
becomes a target call to handle_lost_or_stolen_card.
Every tool receives the original customer message. One complete schema is:
{
"type": "function",
"function": {
"name": "handle_lost_or_stolen_card",
"description": "Handle a card reported lost or stolen.",
"parameters": {
"type": "object",
"properties": {
"customer_message": {
"type": "string",
"description": "The original customer support message."
}
},
"required": [
"customer_message"
]
},
"return": {
"type": "string"
}
}
}
| Function | Intended request |
|---|---|
handle_card_arrival | Handle questions about when a newly ordered card will arrive. |
handle_card_not_working | Handle reports that a physical bank card does not work. |
handle_cash_withdrawal_not_recognised | Handle an unrecognized cash withdrawal. |
handle_change_pin | Handle requests to change a card PIN. |
handle_compromised_card | Handle reports that card details may be compromised. |
handle_lost_or_stolen_card | Handle a card reported lost or stolen. |
handle_pending_card_payment | Handle a card payment that is still pending. |
handle_terminate_account | Handle requests to close a bank account. |
handle_transfer_not_received_by_recipient | Handle a transfer the recipient has not received. |
handle_verify_my_identity | Handle questions about completing identity verification. |
import re
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "lxyuan/FunctionGemma-270M-banking77-router"
TOOL_DESCRIPTIONS = {
"handle_card_arrival": "Handle questions about when a newly ordered card will arrive.",
"handle_card_not_working": "Handle reports that a physical bank card does not work.",
"handle_cash_withdrawal_not_recognised": "Handle an unrecognized cash withdrawal.",
"handle_change_pin": "Handle requests to change a card PIN.",
"handle_compromised_card": "Handle reports that card details may be compromised.",
"handle_lost_or_stolen_card": "Handle a card reported lost or stolen.",
"handle_pending_card_payment": "Handle a card payment that is still pending.",
"handle_terminate_account": "Handle requests to close a bank account.",
"handle_transfer_not_received_by_recipient": "Handle a transfer the recipient has not received.",
"handle_verify_my_identity": "Handle questions about completing identity verification."
}
def make_tool(name: str, description: str) -> dict:
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": {
"customer_message": {"type": "string"},
},
"required": ["customer_message"],
},
"return": {"type": "string"},
},
}
tools = [make_tool(name, description) for name, description in TOOL_DESCRIPTIONS.items()]
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
message = "My card was stolen last night"
inputs = tokenizer.apply_chat_template(
[
{
"role": "developer",
"content": "You route customer requests by calling exactly one banking support tool.",
},
{"role": "user", "content": message},
],
tools=tools,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
).to(model.device)
output = model.generate(**inputs, max_new_tokens=64, do_sample=False)
generated = tokenizer.decode(
output[0, inputs["input_ids"].shape[1] :],
skip_special_tokens=False,
)
match = re.search(
r"<start_function_call>call:([a-z0-9_]+).*?<end_function_call>",
generated,
re.DOTALL,
)
if match is None:
raise ValueError(f"No complete function call: {generated!r}")
print({"tool": match.group(1), "raw_call": match.group(0)})
The model selects a call; it does not execute the tool. Validate arguments and dispatch through an explicit allow-listed handler map.
| Customer input | First generated tool |
|---|---|
I am sick of this damn company and want to close out my account. | handle_terminate_account |
My card doesn't work. | handle_card_not_working |
My card is gone I think it was stolen | handle_lost_or_stolen_card |
There is a withdrawal that isn't mind in the app. | handle_cash_withdrawal_not_recognised |
When will I get my card? | handle_card_arrival |
Can i change my PIN at the ATM? | handle_change_pin |
Input: I am sick of this damn company and want to close out my account.
Output: <start_function_call>call:handle_terminate_account{customer_message:<escape>I am sick of this damn company and want to close out my account.<escape>}<end_function_call>
This run uses TRL SFTTrainer with loss_type="chunked_nll". This is the
standard causal-language-model next-token negative log-likelihood, or
cross-entropy, computed in memory-saving chunks:
loss = mean(-log P(correct next token | previous tokens))
assistant_only_loss=False, and this is a conversational language-modeling
dataset rather than a prompt-completion dataset. Therefore every non-padding
token in the rendered developer prompt, tool declarations, user message, and
assistant call contributes. Padding labels use -100 and are ignored. Exact
generated tool accuracy is a separate application metric and is not the
differentiable loss. Chunking does not alter this token selection; the label
mask controls which tokens contribute, while loss_type controls how the same
calculation is held in memory. See the
TRL 1.12 SFT documentation.
| Metric | Value |
|---|---|
| Base-model exact first-tool accuracy | 51.00% |
| Fine-tuned exact first-tool accuracy | 97.00% |
| Generated evaluation examples | 100 |
| Final evaluation loss | 0.0492 |
| Mean training loss | 0.0959 |
Training loss can continue falling while validation loss rises because the model becomes more confident on repeated training rows without improving equally on unseen rows. Cross-entropy can increase from a few confidently wrong tokens even when average token accuracy changes little.
TensorBoard event files, trainer_state.json, and training_metrics.json are
included in this repository for inspecting the training curves and recorded
results.
| Setting | Value |
|---|---|
| Training examples | 800 |
| Evaluation examples | 200 |
| Epochs | 4 |
| Learning rate | 5e-05 |
| Effective batch size | 8 |
| Maximum sequence length | 1024 |
| Loss | chunked_nll, full rendered sequence except padding |
| Warmup | 0.1 steps in the reference run (effectively no warmup) |
| Seed | 42 |
Software:
datasets==5.0.1tensorboard==2.20.0torch==2.11.0+cu128transformers==5.16.1trl==1.12.0Load the saved FP32 checkpoint without forcing all weights to FP16. The verified FP32 Hub reload produced a valid function call, while forced pure FP16 on a T4 produced padding-only output. Training used FP16 autocast around FP32 master weights.
The BANKING77 mirror describes the dataset as CC BY 4.0. FunctionGemma weights remain subject to the Gemma terms.
9 commits