Instructions to use echoforest/naver-psy-classifier-gemma4-12b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use echoforest/naver-psy-classifier-gemma4-12b with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
File size: 10,677 Bytes
210f9af | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | """Inference server for node12 behind ``api.askdol.com`` at ``/naver`` (classifier-plan §6.2 option (a), §6.4).
Run as ``python -m training.serve --package DIR --base DIR [--host 127.0.0.1 --port 8012]``. ``--package`` is a
pinned snapshot of the private HF repo (``export_hf`` layout: adapter/, heads.safetensors, taxonomy.json,
temperatures.json, config.json, MODEL_REVISION); ``--base`` is the local Gemma 4 12B checkpoint it was trained on.
Endpoints (Bearer key from ``NAVER_PSY_CLASSIFIER_API_KEY``, compared in constant time, never logged):
- ``GET /naver/health`` -> repo, revision, model_revision, taxonomy (canonical), temperatures, fields, max_batch;
- ``POST /naver/predict`` ``{"inputs": [{"id", "segments": [{"kind", "text"}]}]}`` -> per input raw logits,
calibrated gated probabilities and labels, ``n_tokens`` and ``truncated``. Post text is never echoed or logged.
The torch-free core ``Service`` takes a ``predict_fn(encoded ids) -> list of {field: logits}``, so it is tested
without a model; ``load_service`` builds the real one.
"""
from __future__ import annotations
import argparse
import hmac
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Callable, Mapping
from .calibrate import outputs
from .formats import encode
from .taxonomy import Taxonomy
log = logging.getLogger("training.serve")
API_KEY_ENV = "NAVER_PSY_CLASSIFIER_API_KEY"
REVISION_ENV = "NAVER_PSY_MODEL_REVISION"
RENDER_VERSION = "nsb-input-v1"
PREFIX = "/naver"
REVISION_RE = re.compile(r"^[^@\s]+@[0-9a-f]{40}$")
MAX_SEGMENTS = 16
MAX_TEXT = 20_000
PredictFn = Callable[[list[list[int]]], list[dict[str, list[float]]]]
class RequestError(ValueError):
"""A malformed request (HTTP 400/413); the message never contains post text."""
def __init__(self, message: str, status: int = 400) -> None:
super().__init__(message)
self.status = status
class Service:
"""Encode, predict, calibrate and gate one batch (torch-free)."""
def __init__(self, predict_fn: PredictFn, tokenize: Callable[[str], list[int]], bos: int | None, tax: Taxonomy,
temps: Mapping[str, float], model_revision: str, repo: str | None = None,
revision: str | None = None, max_len: int = 2048, max_batch: int = 64,
render_version: str = RENDER_VERSION) -> None:
if not REVISION_RE.match(model_revision):
raise ValueError("model_revision must be '<repo>@<40-hex commit>'")
r, c = model_revision.split("@")
if (repo and repo != r) or (revision and revision != c):
raise ValueError("repo/revision differ from model_revision")
if set(temps) != set(tax.names):
raise ValueError("temperatures do not cover the taxonomy fields")
self.predict_fn, self.tokenize, self.bos, self.tax = predict_fn, tokenize, bos, tax
self.temps = {f: float(temps[f]) for f in tax.names}
self.model_revision, self.repo, self.revision = model_revision, r, c
self.max_len, self.max_batch, self.render_version = max_len, max_batch, render_version
def health(self) -> dict[str, Any]:
return {"status": "ok", "repo": self.repo, "revision": self.revision, "model_revision": self.model_revision,
"taxonomy_version": self.tax.version, "render_version": self.render_version,
"taxonomy": self.tax.to_json(), "temperatures": self.temps, "fields": self.tax.names,
"max_batch": self.max_batch, "max_len": self.max_len}
def _validate(self, inputs: Any) -> list[tuple[str, list[dict]]]:
if not isinstance(inputs, list) or not inputs:
raise RequestError("inputs must be a non-empty list")
if len(inputs) > self.max_batch:
raise RequestError(f"batch of {len(inputs)} exceeds max_batch {self.max_batch}", 413)
out, seen = [], set()
for i, item in enumerate(inputs):
if not isinstance(item, Mapping) or not isinstance(item.get("id"), (str, int)):
raise RequestError(f"input {i}: needs an id")
rid = str(item["id"])
if rid in seen:
raise RequestError(f"input {i}: duplicate id")
seen.add(rid)
segs = item.get("segments")
if not isinstance(segs, list) or not segs or len(segs) > MAX_SEGMENTS:
raise RequestError(f"input {i}: segments must be a list of 1-{MAX_SEGMENTS}")
clean = []
for s in segs:
if (not isinstance(s, Mapping) or not isinstance(s.get("kind"), str)
or not isinstance(s.get("text"), str) or len(s["text"]) > MAX_TEXT):
raise RequestError(f"input {i}: each segment needs a string kind and text (<= {MAX_TEXT} chars)")
clean.append({"kind": s["kind"], "text": s["text"]})
out.append((rid, clean))
return out
def predict(self, inputs: Any) -> dict[str, Any]:
items = self._validate(inputs)
encoded, infos = [], []
for i, (_, segs) in enumerate(items):
try:
ids, info = encode(segs, self.tokenize, self.max_len, self.bos)
except ValueError:
raise RequestError(f"input {i}: cannot be encoded within max_len") from None
encoded.append(ids)
infos.append(info)
logits = self.predict_fn(encoded)
if len(logits) != len(items):
raise RuntimeError("predict_fn returned a different number of rows")
results = []
for (rid, _), lg, info in zip(items, logits, infos):
row = {f: [float(x) for x in lg[f]] for f in self.tax.names}
out = outputs(row, self.tax, self.temps)
results.append({"id": rid, "logits": row, "probs": out["probs"], "labels": out["labels"],
"n_tokens": info["n_tokens"], "truncated": bool(info["cut"])})
return {"model_revision": self.model_revision, "results": results}
def authorised(header: str | None, key: str | None) -> bool:
"""Constant-time Bearer check; no key configured means every request is refused."""
if not key or not header or not header.startswith("Bearer "):
return False
return hmac.compare_digest(header[len("Bearer "):].encode(), key.encode())
def create_app(service: Service, api_key: str | None = None, prefix: str = PREFIX):
"""FastAPI app around a Service (FastAPI is imported here so the core needs nothing)."""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
app = FastAPI(title="naver-psy classifier", docs_url=None, redoc_url=None, openapi_url=None)
def denied() -> JSONResponse:
return JSONResponse({"error": "unauthorised"}, status_code=401)
def health(request):
if not authorised(request.headers.get("authorization"), key):
return denied()
return service.health()
async def predict(request):
if not authorised(request.headers.get("authorization"), key):
return denied()
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "body must be JSON"}, status_code=400)
try:
return service.predict(body.get("inputs") if isinstance(body, dict) else None)
except RequestError as e:
return JSONResponse({"error": str(e)}, status_code=e.status)
except Exception as e: # never echo the input; log the class only
log.error("predict failed: %s", type(e).__name__)
return JSONResponse({"error": "internal error"}, status_code=500)
# Real annotation objects (this module uses postponed annotations, which FastAPI cannot resolve locally).
health.__annotations__["request"] = Request
predict.__annotations__["request"] = Request
app.get(prefix + "/health")(health)
app.post(prefix + "/predict")(predict)
return app
def read_revision(package: Path) -> str:
env = os.environ.get(REVISION_ENV)
if env:
return env.strip()
path = package / "MODEL_REVISION"
if not path.exists():
raise SystemExit(f"no model revision: set {REVISION_ENV} or write {path}")
return path.read_text().strip()
def load_service(package_dir, base_dir, device: str = "cuda", max_batch: int = 64) -> Service:
"""Build the torch model from an HF package snapshot and a local base checkpoint."""
import torch
from .data import inference_batches
from .model import bos_id, build, load_tokenizer, predict, tokenizer_fn
package = Path(package_dir)
cfg = json.loads((package / "config.json").read_text())
cfg["model"] = str(base_dir)
tax = Taxonomy.load(package / "taxonomy.json")
temps = json.loads((package / "temperatures.json").read_text())
dev = torch.device(device)
model = build(cfg, tax.sizes(), dev, train=False, adapter_dir=package / "adapter",
heads_path=package / "heads.safetensors")
tok = load_tokenizer(str(base_dir))
max_len = int(cfg.get("max_len", 2048))
budget = int(cfg.get("serve_token_budget", 65536))
def predict_fn(encoded: list[list[int]]) -> list[dict[str, list[float]]]:
batches = inference_batches([len(x) for x in encoded], budget)
out = predict(model, encoded, tok.pad_token_id, batches, dev, tax.names)
return [out[i] for i in range(len(encoded))]
return Service(predict_fn, tokenizer_fn(tok), bos_id(tok), tax, temps, read_revision(package),
max_len=max_len, max_batch=max_batch)
def main(argv=None) -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--package", required=True, help="Pinned HF package snapshot")
p.add_argument("--base", required=True, help="Local base checkpoint directory")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8012)
p.add_argument("--device", default="cuda")
p.add_argument("--max-batch", type=int, default=64)
args = p.parse_args(argv)
if not os.environ.get(API_KEY_ENV):
raise SystemExit(f"{API_KEY_ENV} is not set; refusing to serve without auth")
import uvicorn
logging.basicConfig(level=logging.INFO)
service = load_service(args.package, args.base, args.device, args.max_batch)
log.info("serving %s", service.model_revision)
uvicorn.run(create_app(service), host=args.host, port=args.port, access_log=False)
return 0
if __name__ == "__main__":
raise SystemExit(main())
|