FoodGuard-4B / inference.py
EddyLuo's picture
Release FoodGuard-4B seed42 with paper results and inference examples
a8748a2 verified
Raw History Blame Contribute Delete
4.48 kB
"""FoodGuard binary classification using the checkpoint's original policy."""
import argparse
import json
import re
from pathlib import Path
import torch
from huggingface_hub import hf_hub_download
from transformers import AutoModelForCausalLM, AutoTokenizer
class FoodGuard:
def __init__(self, model, device='auto', revision=None):
local = Path(model).is_dir()
common = {'local_files_only': local, 'trust_remote_code': False}
if revision is not None:
common['revision'] = revision
policy_file = Path(model) / 'classifier_policy.txt' if local else Path(
hf_hub_download(model, 'classifier_policy.txt', revision=revision))
self.policy = policy_file.read_text(encoding='utf-8').rstrip('\n')
self.tokenizer = AutoTokenizer.from_pretrained(model, **common)
use_gpu = device == 'cuda' or (device == 'auto' and torch.cuda.is_available())
if device == 'cuda' and not torch.cuda.is_available():
raise RuntimeError('CUDA requested but unavailable')
self.device = 'cuda:0' if use_gpu else 'cpu'
dtype = (torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16) if use_gpu else torch.float32
self.model = AutoModelForCausalLM.from_pretrained(
model, dtype=dtype, device_map={'': self.device},
attn_implementation='sdpa', low_cpu_mem_usage=True, **common,
).eval()
def classify(self, text):
prompt = self.tokenizer.apply_chat_template([
{'role': 'system', 'content': self.policy},
{'role': 'user', 'content': text},
], tokenize=False, add_generation_prompt=True)
inputs = self.tokenizer(prompt, add_special_tokens=False, return_tensors='pt')
length = inputs.input_ids.shape[1]
if length > 5120:
raise ValueError(f'Input has {length} tokens; maximum is 5120. Input was not truncated.')
inputs = inputs.to(self.device)
with torch.inference_mode():
output = self.model.generate(
**inputs, do_sample=False, max_new_tokens=16,
pad_token_id=self.tokenizer.pad_token_id if self.tokenizer.pad_token_id is not None else self.tokenizer.eos_token_id,
)
answer = self.tokenizer.decode(output[0, length:], skip_special_tokens=True).strip()
match = re.fullmatch(r'Safety Assessment:\s*(safe|unsafe)', answer, re.I)
return {'predicted_label': match.group(1).lower() if match else None,
'final_answer': answer, 'input_tokens': length}
def main():
parser = argparse.ArgumentParser(description='Classify food-related input with FoodGuard')
parser.add_argument('--model', required=True, help='Local model directory or Hugging Face model ID')
parser.add_argument('--revision', help='Optional Hugging Face commit or tag')
parser.add_argument('--device', choices=['auto', 'cuda', 'cpu'], default='auto')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--text')
group.add_argument('--input-file', type=Path, help='JSONL containing an input field')
parser.add_argument('--output-file', type=Path)
args = parser.parse_args()
if args.input_file and not args.output_file:
parser.error('--input-file requires --output-file')
if args.input_file and args.input_file.resolve() == args.output_file.resolve():
parser.error('Input and output files must differ')
guard = FoodGuard(args.model, args.device, args.revision)
if args.text is not None:
print(json.dumps(guard.classify(args.text), ensure_ascii=False))
return
args.output_file.parent.mkdir(parents=True, exist_ok=True)
with args.input_file.open(encoding='utf-8') as source, args.output_file.open('w', encoding='utf-8') as target:
for line_number, line in enumerate(source, 1):
if not line.strip():
continue
row = json.loads(line)
if not isinstance(row.get('input'), str):
raise ValueError(f'Line {line_number}: input must be a string')
record = {'line_number': line_number, **guard.classify(row['input'])}
for field in ('group_id', 'template_id', 'label'):
if field in row:
record[field] = row[field]
target.write(json.dumps(record, ensure_ascii=False) + '\n')
target.flush()
if __name__ == '__main__':
main()