File size: 12,497 Bytes
ea1a330 | 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 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | # -*- coding: utf-8 -*-
"""KnowledgeGraphEval 2026 · Subtask 1 (AdaptNER) — generative NER via a HOSTED QWEN endpoint.
Standalone, local, resume-friendly. Mirrors the AlexandriaX `alexandriax_task1_qwen_hosted.py`
pattern: instead of training, it drives an existing **hosted Qwen inference endpoint**
(OpenAI-compatible, e.g. the vLLM server on the femad/data-intelligence stack) to extract
named entities from the Konooz dev/eval sentences, then writes the official CodaBench
submission (token + 21 B/I/O columns, one concatenated file).
Design
------
* No training / no GPU. Pure inference against an existing hosted model.
* Runs on your PC. Outputs saved next to this file under ``qwen_runs/``.
* Direct OpenAI-compatible ``/v1/chat/completions`` call — no orchestrator, no tracing.
* Resume-safe. Each sentence's raw model output is cached to a local JSONL as it lands;
re-run and it only queries the sentences still missing.
Endpoint config (env vars — sane defaults)
------------------------------------------
QWEN_BASE_URL OpenAI-compatible base URL. Default: http://localhost:8005/v1
(reach a remote server via: ssh -N -L 8005:localhost:8005 <host>)
QWEN_MODEL Model name served by the endpoint. Default: qwen3-3b
(SET THIS to whatever your Qwen 3B is registered as; check
with: curl http://localhost:8005/v1/models )
QWEN_API_KEY vLLM ignores it; any non-empty string. Default: "EMPTY".
QWEN_HOST_HEADER Proxy vhost when tunnelling (e.g. "100.64.0.2:8005"). Default: none.
QWEN_NO_THINK "1" (default) disables qwen3 thinking mode (harmless on Instruct).
QWEN_CONCURRENCY Parallel in-flight requests. Default: 8.
QWEN_TEMPERATURE Sampling temperature. Default: 0.0 (deterministic).
QWEN_MAX_TOKENS Max generated tokens per sentence. Default: 512.
QWEN_MAX_RETRIES Transient-error retries. Default: 4.
Data config
-----------
KONOOZ_SRC Path to a .zip OR a folder of <Domain>.txt files.
Default: auto-find under ../../data/raw/konooz_dev then dev-konooz.zip.
Run
---
pip install openai tqdm
QWEN_MODEL=qwen2-2b python kgeval_task1_qwen_hosted.py
"""
from __future__ import annotations
import glob
import io
import json
import os
import re
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from tqdm.auto import tqdm
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ROOT = os.path.dirname(os.path.abspath(__file__))
WORK_DIR = os.path.join(ROOT, "qwen_runs")
SUBMIT_DIR = os.path.join(WORK_DIR, "submissions_qwen")
os.makedirs(SUBMIT_DIR, exist_ok=True)
NAME = os.environ.get("RUN_NAME", "qwen3b_hosted")
CACHE_FILE = os.path.join(SUBMIT_DIR, f"cache_{NAME}.jsonl")
# Official 21 entity types, in submission column order.
TYPE_ORDER = [
"CARDINAL", "CURR", "DATE", "EVENT", "FAC", "GPE", "LANGUAGE", "LAW", "LOC", "MONEY",
"NORP", "OCC", "ORDINAL", "ORG", "PERCENT", "PERS", "PRODUCT", "QUANTITY", "TIME", "UNIT", "WEBSITE",
]
# --- hosted qwen endpoint (direct, untraced) ---
QWEN_BASE_URL = os.environ.get("QWEN_BASE_URL") or os.environ.get("LLM_API_BASE_URL") or "http://localhost:8005/v1"
QWEN_MODEL = os.environ.get("QWEN_MODEL") or os.environ.get("LLM_MODEL_NAME") or "qwen3-3b"
QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "EMPTY")
QWEN_HOST_HEADER = os.environ.get("QWEN_HOST_HEADER", "")
QWEN_NO_THINK = os.environ.get("QWEN_NO_THINK", "1") == "1"
CONCURRENCY = int(os.environ.get("QWEN_CONCURRENCY", "8"))
TEMPERATURE = float(os.environ.get("QWEN_TEMPERATURE", "0.0"))
MAX_TOKENS = int(os.environ.get("QWEN_MAX_TOKENS", "512"))
MAX_RETRIES = int(os.environ.get("QWEN_MAX_RETRIES", "4"))
KONOOZ_SRC = os.environ.get("KONOOZ_SRC", "")
# ---------------------------------------------------------------------------
# Data loading (CoNLL: token + N columns; blank line = sentence boundary)
# ---------------------------------------------------------------------------
def resolve_konooz_src() -> str:
if KONOOZ_SRC:
return KONOOZ_SRC
guesses = [
os.path.join(ROOT, "..", "..", "data", "raw", "konooz_dev"),
os.path.join(ROOT, "..", "..", "data", "raw", "dev-konooz.zip"),
os.path.join(ROOT, "..", "..", "data", "raw", "devkonooz.zip"),
]
for g in guesses:
if os.path.exists(g):
return g
raise FileNotFoundError("Set KONOOZ_SRC to the Konooz dev .zip or folder.")
def load_domain_files(src: str):
if str(src).endswith(".zip"):
work = os.path.join(WORK_DIR, "konooz_dev")
os.makedirs(work, exist_ok=True)
with zipfile.ZipFile(src) as z:
z.extractall(work)
src = work
files = sorted(glob.glob(os.path.join(src, "**", "*.txt"), recursive=True))
if not files:
raise FileNotFoundError(f"no .txt files under {src}")
return files
def read_conll(path: str):
sents, cur = [], []
for line in io.open(path, encoding="utf-8"):
s = line.rstrip("\n")
if not s.strip():
if cur:
sents.append(cur)
cur = []
continue
cur.append(s.split()) # parts[0] = token, parts[1:] = placeholder tags (ignored)
if cur:
sents.append(cur)
return sents
# ---------------------------------------------------------------------------
# Qwen client (direct OpenAI-compatible call)
# ---------------------------------------------------------------------------
from openai import OpenAI
_client = OpenAI(
base_url=QWEN_BASE_URL,
api_key=QWEN_API_KEY,
max_retries=0,
default_headers={"Host": QWEN_HOST_HEADER} if QWEN_HOST_HEADER else None,
)
_TYPE_LIST_STR = ", ".join(TYPE_ORDER)
_SYSTEM = (
"You are an expert Arabic Named Entity Recognition system. "
"Given an Arabic sentence, extract every named entity. "
"Allowed entity types (use these labels exactly): " + _TYPE_LIST_STR + ". "
"Entities may be nested (a span can belong to more than one type). "
"Output ONLY the entities, one per line, as: <surface text><TAB><TYPE>. "
"Copy the surface text exactly as it appears in the sentence. "
"If there are no entities, output nothing."
)
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
_SPECIAL_TOK_RE = re.compile(r"<\|[^|>]*\|>")
def clean_output(text: str) -> str:
if not text:
return ""
text = _THINK_RE.sub("", text)
if "</think>" in text:
text = text.split("</think>")[-1]
return _SPECIAL_TOK_RE.sub("", text).strip()
def extract_one(sentence: str) -> str:
"""Return the raw model text (entity lines) for one sentence."""
if not sentence.strip():
return ""
kwargs = dict(
model=QWEN_MODEL,
messages=[{"role": "system", "content": _SYSTEM},
{"role": "user", "content": sentence}],
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
)
if QWEN_NO_THINK:
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
last_err = None
for _ in range(MAX_RETRIES):
try:
resp = _client.chat.completions.create(**kwargs)
return clean_output(resp.choices[0].message.content or "")
except Exception as e: # transient network / 5xx / rate-limit
last_err = e
raise RuntimeError(f"qwen call failed after {MAX_RETRIES} retries: {last_err}")
# ---------------------------------------------------------------------------
# Parse model text -> per-token 21-column tags
# ---------------------------------------------------------------------------
_TYPESET = set(TYPE_ORDER)
def parse_entities(text: str):
ents = []
for ln in text.splitlines():
ln = ln.strip()
if not ln:
continue
parts = re.split(r"\t|\s+-\s+|\s*:\s*|\s*\|\s*", ln)
if len(parts) >= 2:
typ = parts[-1].strip().upper()
surf = " ".join(parts[:-1]).strip().strip("\"'“”«»")
if typ in _TYPESET and surf:
ents.append((surf, typ))
return ents
def entity_tagsets(tokens, ents):
"""Align each entity surface (whitespace tokens) onto `tokens`; per-token set of B-/I- tags."""
tagsets = [set() for _ in tokens]
for surf, typ in ents:
span = surf.split()
if not span:
continue
for i in range(len(tokens) - len(span) + 1):
if tokens[i:i + len(span)] == span:
tagsets[i].add(f"B-{typ}")
for j in range(i + 1, i + len(span)):
tagsets[j].add(f"I-{typ}")
return tagsets
def tags_to_columns(tagset):
cols = []
for typ in TYPE_ORDER:
if f"B-{typ}" in tagset:
cols.append(f"B-{typ}")
elif f"I-{typ}" in tagset:
cols.append(f"I-{typ}")
else:
cols.append("O")
return cols
def read_jsonl(p):
rows = []
with io.open(p, encoding="utf-8") as h:
for line in h:
if line.strip():
rows.append(json.loads(line))
return rows
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
print(f"endpoint : {QWEN_BASE_URL}")
print(f"model : {QWEN_MODEL} (no_think={QWEN_NO_THINK}, temp={TEMPERATURE}, conc={CONCURRENCY})")
print(f"work dir : {WORK_DIR}")
src = resolve_konooz_src()
files = load_domain_files(src)
data = {os.path.splitext(os.path.basename(f))[0]: read_conll(f) for f in files}
total = sum(len(v) for v in data.values())
print(f"data : {len(files)} domains, {total} sentences ({src})")
# --- sanity: first 2 sentences ---
print("\n--- sanity (first 2 sentences) ---")
dom0 = sorted(data)[0]
for sent in data[dom0][:2]:
toks = [r[0] for r in sent]
raw = extract_one(" ".join(toks))
print(f"[{dom0}] {' '.join(toks)[:50]!r} -> {parse_entities(raw)[:4]}")
print()
# --- resume from cache (keyed by domain, sentence index) ---
cache = {}
if os.path.exists(CACHE_FILE):
for r in read_jsonl(CACHE_FILE):
cache[(r["domain"], int(r["sidx"]))] = r["text"]
print(f"[resume] {len(cache)} sentences already cached")
todo = []
for dom in sorted(data):
for sidx, sent in enumerate(data[dom]):
if (dom, sidx) not in cache:
todo.append((dom, sidx, " ".join(r[0] for r in sent)))
print("remaining sentences:", len(todo))
if todo:
fh = io.open(CACHE_FILE, "a", encoding="utf-8")
try:
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futs = {pool.submit(extract_one, s): (d, i) for (d, i, s) in todo}
for fut in tqdm(as_completed(futs), total=len(futs), desc="extract", unit="sent"):
d, i = futs[fut]
text = fut.result()
cache[(d, i)] = text
fh.write(json.dumps({"domain": d, "sidx": i, "text": text}, ensure_ascii=False) + "\n")
fh.flush()
finally:
fh.close()
print("total cached:", len(cache))
# --- build official submission: token + 21 columns, domains concatenated alphabetically ---
pred_name = f"namaa_pred_{NAME}.txt"
pred_path = os.path.join(SUBMIT_DIR, pred_name)
n_ent = 0
with io.open(pred_path, "w", encoding="utf-8") as f:
for dom in sorted(data):
for sidx, sent in enumerate(data[dom]):
toks = [r[0] for r in sent]
ents = parse_entities(cache.get((dom, sidx), ""))
n_ent += len(ents)
tagsets = entity_tagsets(toks, ents)
for w, tok in enumerate(toks):
f.write(tok + " " + " ".join(tags_to_columns(tagsets[w])) + "\n")
f.write("\n")
print(f"predictions -> {pred_path} (entities aligned: {n_ent})")
zip_path = os.path.join(SUBMIT_DIR, f"submission_{NAME}.zip")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
z.write(pred_path, arcname=pred_name)
print("submission ->", zip_path)
print("[done] upload submission zip to CodaBench.")
if __name__ == "__main__":
main()
|