code_portrait_track_1 / convert_to_answer.py
Artoria0429's picture
Upload 15 files
617bcee verified
Raw
History Blame Contribute Delete
19 kB
import argparse
import copy
import json
import os
import re
from collections import Counter
from glob import glob
from typing import Any, Dict, List, Optional, Tuple
DEFAULT_TEMPLATE_PATH = "outputs/predictions/track_1_test.json"
DEFAULT_PRED_PATH = "outputs/predictions/predict_ckpt2660/generated_predictions.jsonl"
DEFAULT_OUTPUT_PATH = "outputs/submissions/answers/track_1_test.json"
def load_json(path: str) -> Any:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def load_jsonl(path: str) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
with open(path, "r", encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
raw = line.strip()
if not raw:
continue
try:
rows.append(json.loads(raw))
except Exception:
print(f"[WARN] skip invalid jsonl line: {line_no}")
return rows
def normalize_level(level: Any) -> Optional[str]:
if level is None:
return None
s = str(level).strip()
if not s:
return None
m = {
"poor": "Poor",
"medium": "Medium",
"good": "Good",
"a": "A",
"b": "B",
"c": "C",
}
return m.get(s.lower(), s)
def score_to_submission_level(score: float, low_threshold: float = 5.0, high_threshold: float = 7.0) -> str:
# 比赛映射:A=Poor(0~5), B=Medium(5~7), C=Good(7~10)
if score < low_threshold:
return "A"
if score < high_threshold:
return "B"
return "C"
def to_submission_level(level: Any) -> Optional[str]:
"""
Submission mapping required by Track-1 template:
A -> Poor, B -> Medium, C -> Good
"""
norm = normalize_level(level)
if norm is None:
return None
m = {
"Poor": "A",
"Medium": "B",
"Good": "C",
"A": "A",
"B": "B",
"C": "C",
}
return m.get(norm)
def normalize_prediction_text(pred_row: Dict[str, Any]) -> str:
# 兼容不同推理后端字段名。
for k in ("predict", "response", "output", "text", "generation"):
if k in pred_row:
return str(pred_row.get(k, ""))
return str(pred_row)
def extract_level_from_text(pred_text: str, crit_key: str) -> Optional[str]:
pattern = rf'"{re.escape(crit_key)}"\s*:\s*\{{.*?"level"\s*:\s*"([^"]+)"'
m = re.search(pattern, pred_text, flags=re.IGNORECASE | re.DOTALL)
if not m:
return None
return to_submission_level(m.group(1))
def try_parse_predict_json(text: str) -> Optional[Dict[str, Any]]:
text = str(text or "").strip()
if not text:
return None
# 1) 直接解析
try:
obj = json.loads(text)
if isinstance(obj, dict):
return obj
except Exception:
pass
# 2) 尝试提取从首个 { 到最后一个 } 的片段
left = text.find("{")
right = text.rfind("}")
if left != -1 and right != -1 and left < right:
snippet = text[left:right + 1]
try:
obj = json.loads(snippet)
if isinstance(obj, dict):
return obj
except Exception:
pass
return None
def extract_answer(pred_obj: Optional[Dict[str, Any]], pred_text: str) -> Optional[str]:
if pred_obj is not None:
answer = str(pred_obj.get("answer", "")).strip().upper()
if answer in {"A", "B", "C", "D"}:
return answer
m = re.search(r'"answer"\s*:\s*"([A-D])"', pred_text, re.IGNORECASE)
if m:
return m.group(1).upper()
# 兼容 QA-only 推理:模型可能只输出单个字母(如 "C")。
raw = str(pred_text or "").strip().upper()
if raw:
# 情况1:整行仅有一个候选字母(允许尾随标点)。
m = re.match(r"^\s*([A-D])(?:[\.\)\]::]|\s)*$", raw)
if m:
return m.group(1)
# 情况2:短文本中只出现唯一一个 A/B/C/D,且不包含常见 JSON/选项结构。
if len(raw) <= 12 and ("{" not in raw) and ("\"" not in raw):
hits = re.findall(r"[A-D]", raw)
if len(hits) == 1:
return hits[0]
return None
def extract_total_score(pred_obj: Optional[Dict[str, Any]], pred_text: str) -> Optional[float]:
val: Optional[float] = None
if pred_obj is not None and "total_score" in pred_obj:
try:
val = float(pred_obj["total_score"])
except Exception:
val = None
if val is None:
m = re.search(r'"total_score"\s*:\s*([0-9]+(?:\.[0-9]+)?)', pred_text)
if m:
val = float(m.group(1))
if val is None:
return None
return max(0.0, min(100.0, float(val)))
def merge_total_scores(total_votes: List[float], mode: str) -> Optional[int]:
if not total_votes:
return None
vals = [float(v) for v in total_votes]
if mode == "trim_mean" and len(vals) >= 3:
vals = sorted(vals)[1:-1]
merged = sum(vals) / len(vals)
return max(0, min(100, int(round(merged))))
def parse_score_from_text(pred_text: str, crit_key: str) -> Optional[float]:
pattern = rf'"{re.escape(crit_key)}"\s*:\s*\{{.*?"score"\s*:\s*([0-9]+(?:\.[0-9]+)?)'
m = re.search(pattern, pred_text, flags=re.IGNORECASE | re.DOTALL)
if not m:
return None
try:
return float(m.group(1))
except Exception:
return None
def extract_one_criteria_level_and_score(
crit_key: str,
pred_obj: Optional[Dict[str, Any]],
pred_text: str,
) -> Tuple[Optional[str], Optional[float]]:
score: Optional[float] = None
level: Optional[str] = None
if isinstance(pred_obj, dict):
src = pred_obj.get("criteria", {})
if isinstance(src, dict) and crit_key in src:
value = src.get(crit_key)
if isinstance(value, dict):
if "score" in value:
try:
score = float(value["score"])
except Exception:
score = None
level = to_submission_level(value.get("level"))
else:
level = to_submission_level(value)
if score is None:
score = parse_score_from_text(pred_text, crit_key)
if level is None:
level = extract_level_from_text(pred_text, crit_key)
if level not in {"A", "B", "C"}:
level = None
return level, score
def choose_majority_level(
levels: List[str],
scores: List[float],
fallback_level: Optional[str],
low_threshold: float,
high_threshold: float,
) -> str:
if levels:
cnt = Counter(levels)
top_n = max(cnt.values())
top_levels = sorted([k for k, v in cnt.items() if v == top_n])
if len(top_levels) == 1:
return top_levels[0]
# 平票时,用多次预测的均值 score 判定等级。
if scores:
mean_score = sum(scores) / len(scores)
return score_to_submission_level(mean_score, low_threshold=low_threshold, high_threshold=high_threshold)
if fallback_level in {"A", "B", "C"}:
return fallback_level
return "B"
def parse_weights(raw_weights: Optional[List[float]], n_models: int) -> List[float]:
# 默认等权;若传入权重则要求和预测文件数一致。
if raw_weights is None:
return [1.0] * n_models
if len(raw_weights) != n_models:
raise ValueError(
f"--weights length ({len(raw_weights)}) must equal number of prediction files ({n_models})."
)
for w in raw_weights:
if w < 0:
raise ValueError("weights must be non-negative.")
# 全零没有意义,回退等权。
if sum(raw_weights) == 0:
return [1.0] * n_models
return raw_weights
def load_thresholds(
thresholds_json: str,
) -> Tuple[Dict[str, Dict[str, float]], Dict[str, float]]:
"""
Accepts JSON in either format:
1) {"criteria": {"Color Harmony": {"low": 4.9, "high": 7.1}}, "default": {"low":5,"high":7}}
2) {"Color Harmony": {"low": 4.9, "high": 7.1}, ...}
"""
default = {"low": 5.0, "high": 7.0}
per_criteria: Dict[str, Dict[str, float]] = {}
if not thresholds_json:
return per_criteria, default
if not os.path.exists(thresholds_json):
print(f"[WARN] thresholds file not found: {thresholds_json}, fallback to default 5/7")
return per_criteria, default
obj = load_json(thresholds_json)
if not isinstance(obj, dict):
print(f"[WARN] invalid thresholds json format: {thresholds_json}, fallback to default 5/7")
return per_criteria, default
if "default" in obj and isinstance(obj.get("default"), dict):
d = obj["default"]
low = d.get("low", 5.0)
high = d.get("high", 7.0)
try:
low_f = float(low)
high_f = float(high)
if low_f < high_f:
default = {"low": low_f, "high": high_f}
except Exception:
pass
src = obj.get("criteria") if isinstance(obj.get("criteria"), dict) else obj
if isinstance(src, dict):
for k, v in src.items():
if not isinstance(v, dict):
continue
if "low" not in v or "high" not in v:
continue
try:
low = float(v["low"])
high = float(v["high"])
except Exception:
continue
if low < high:
per_criteria[str(k)] = {"low": low, "high": high}
return per_criteria, default
def resolve_best_index(best_index: int, weights: List[float]) -> int:
# best_index=-1 表示自动选择权重最高的模型作为平票时的优先模型。
if best_index >= 0:
if best_index >= len(weights):
raise ValueError(f"--best_index out of range: {best_index}, num_models={len(weights)}")
return best_index
return max(range(len(weights)), key=lambda i: weights[i])
def extract_criteria_voting(
template_item: Dict[str, Any],
pred_objs: List[Optional[Dict[str, Any]]],
pred_texts: List[str],
per_criteria_thresholds: Dict[str, Dict[str, float]],
default_thresholds: Dict[str, float],
) -> Dict[str, Dict[str, str]]:
out: Dict[str, Dict[str, str]] = {}
for crit_key, crit_val in template_item.get("criteria", {}).items():
thresholds = per_criteria_thresholds.get(crit_key, default_thresholds)
low = float(thresholds.get("low", 5.0))
high = float(thresholds.get("high", 7.0))
if not (low < high):
low, high = 5.0, 7.0
level_votes: List[str] = []
score_votes: List[float] = []
for obj, text in zip(pred_objs, pred_texts):
level, score = extract_one_criteria_level_and_score(crit_key, obj, text)
if level is not None:
level_votes.append(level)
if score is not None:
score_votes.append(score)
prev = str(crit_val.get("level", "")).strip() if isinstance(crit_val, dict) else ""
final_level = choose_majority_level(
level_votes,
score_votes,
prev,
low_threshold=low,
high_threshold=high,
)
out[crit_key] = {"level": final_level}
return out
def pick_default_predictions_path() -> str:
candidates = [
"outputs/predictions/predict_ckpt2660/generated_predictions.jsonl",
"outputs/predictions/generated_predictions.jsonl",
]
# 自动兜底:在预测目录里找最近一次 generated_predictions.jsonl。
dynamic = sorted(
glob("outputs/predictions/**/generated_predictions.jsonl", recursive=True),
key=lambda x: os.path.getmtime(x),
reverse=True,
)
candidates = dynamic + candidates
for p in candidates:
if os.path.exists(p):
return p
return DEFAULT_PRED_PATH
def choose_weighted_answer(
votes_by_model: List[Optional[str]],
weights: List[float],
best_index: int,
tie_break_answer: Optional[str],
) -> str:
"""
更合理的答案融合策略:
1) 加权投票(按各变体可靠性权重)
2) 若平票且提供 tie-break 结果,则优先用 tie-break
3) 若仍平票,采用最佳变体(best_index)在平票选项中的答案
4) 最后才做稳定兜底(字母序)
"""
label_scores = {"A": 0.0, "B": 0.0, "C": 0.0, "D": 0.0}
for i, ans in enumerate(votes_by_model):
if ans in label_scores:
label_scores[ans] += weights[i]
max_score = max(label_scores.values())
if max_score <= 0:
return "A"
tied = sorted([k for k, v in label_scores.items() if v == max_score])
if len(tied) == 1:
return tied[0]
if tie_break_answer in tied:
return tie_break_answer # 用专门 tie-break 结果判平票
best_vote = votes_by_model[best_index]
if best_vote in tied:
return str(best_vote)
return tied[0]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--template_json", type=str, default=DEFAULT_TEMPLATE_PATH)
parser.add_argument(
"--predictions_jsonl",
type=str,
nargs="+",
default=None,
help="One or more generated_predictions.jsonl paths for voting.",
)
parser.add_argument(
"--weights",
type=float,
nargs="+",
default=None,
help="Optional weights for prediction files (same length as --predictions_jsonl).",
)
parser.add_argument(
"--best_index",
type=int,
default=-1,
help="Best model index for tie fallback. -1 means auto argmax(weights).",
)
parser.add_argument(
"--tie_break_jsonl",
type=str,
default="",
help="Optional tie-break predictions file used only when weighted vote ties.",
)
parser.add_argument(
"--thresholds_json",
type=str,
default="",
help="Optional per-criterion thresholds json for score->A/B/C mapping.",
)
parser.add_argument(
"--total_score_fusion",
type=str,
default="mean",
choices=["mean", "trim_mean"],
help="Fusion mode for total_score across multi-prompt predictions.",
)
parser.add_argument("--output_json", type=str, default=DEFAULT_OUTPUT_PATH)
args = parser.parse_args()
pred_paths = args.predictions_jsonl if args.predictions_jsonl else [pick_default_predictions_path()]
pred_paths = [p for p in pred_paths if str(p).strip()]
if not pred_paths:
raise ValueError("No predictions_jsonl provided or discovered.")
template_data = load_json(args.template_json)
pred_sets = [load_jsonl(p) for p in pred_paths]
weights = parse_weights(args.weights, len(pred_sets))
best_index = resolve_best_index(args.best_index, weights)
per_criteria_thresholds, default_thresholds = load_thresholds(args.thresholds_json)
tie_break_rows: List[Dict[str, Any]] = []
if args.tie_break_jsonl:
tie_break_rows = load_jsonl(args.tie_break_jsonl)
if not isinstance(template_data, list):
raise ValueError("template_json must be a list.")
print(f"[INFO] template items: {len(template_data)}")
for p, rows in zip(pred_paths, pred_sets):
print(f"[INFO] prediction rows: {len(rows)} ({p})")
print(f"[INFO] answer weights: {weights}")
print(f"[INFO] answer best_index: {best_index}")
print(f"[INFO] criteria thresholds default: low={default_thresholds['low']}, high={default_thresholds['high']}")
if per_criteria_thresholds:
print(f"[INFO] criteria thresholds loaded: {len(per_criteria_thresholds)}")
if args.tie_break_jsonl:
print(f"[INFO] tie_break rows: {len(tie_break_rows)} ({args.tie_break_jsonl})")
final_submission: List[Dict[str, Any]] = []
parsed_ok = 0
filled = 0
for i, item in enumerate(template_data):
new_item = copy.deepcopy(item)
# 收集每次预测在第 i 条样本上的结果。
row_texts: List[str] = []
row_objs: List[Optional[Dict[str, Any]]] = []
answer_votes_by_model: List[Optional[str]] = []
total_votes: List[int] = []
for rows in pred_sets:
if i >= len(rows):
answer_votes_by_model.append(None)
continue
pred_text = normalize_prediction_text(rows[i])
pred_obj = try_parse_predict_json(pred_text)
if pred_obj is not None:
parsed_ok += 1
row_texts.append(pred_text)
row_objs.append(pred_obj)
ans = extract_answer(pred_obj, pred_text)
answer_votes_by_model.append(ans)
ts = extract_total_score(pred_obj, pred_text)
if ts is not None:
total_votes.append(ts)
if not row_texts:
final_submission.append(new_item)
continue
new_item["criteria"] = extract_criteria_voting(
new_item,
row_objs,
row_texts,
per_criteria_thresholds=per_criteria_thresholds,
default_thresholds=default_thresholds,
)
# total_score 用多次预测均值,减少单次抖动。
merged_total = merge_total_scores(total_votes, args.total_score_fusion)
if merged_total is not None:
new_item["total_score"] = max(0, min(100, merged_total))
tie_break_answer: Optional[str] = None
if i < len(tie_break_rows):
tb_text = normalize_prediction_text(tie_break_rows[i])
tb_obj = try_parse_predict_json(tb_text)
tie_break_answer = extract_answer(tb_obj, tb_text)
new_item["answer"] = choose_weighted_answer(
votes_by_model=answer_votes_by_model,
weights=weights,
best_index=best_index,
tie_break_answer=tie_break_answer,
)
final_submission.append(new_item)
filled += 1
os.makedirs(os.path.dirname(args.output_json), exist_ok=True)
with open(args.output_json, "w", encoding="utf-8") as f:
json.dump(final_submission, f, ensure_ascii=False, indent=2)
parsed_total = filled * len(pred_paths)
print(f"[INFO] parsed predict json ok: {parsed_ok}/{parsed_total}")
print(f"[INFO] saved submission: {args.output_json}")
if __name__ == "__main__":
main()