feat(m1): instruction & answer generation, 6 categories (T1.4)

- describe / anomaly / root_cause / forecast / compare / event
- JSON answers (stats, anomaly_regions aligned w/ labels, forecast), text for cause/event
- evolve_instruction: deterministic Evol-Instruct-style rephrasing
- tests/test_instruct.py (12 tests, RED->GREEN)

Exit criteria: anomaly JSON json.loads-able & regions reconstruct labels; full suite 37 passed.
This commit is contained in:
张宗平
2026-06-29 23:26:41 +08:00
parent e2c0756a78
commit 1b4c6ddf4c
3 changed files with 438 additions and 1 deletions
+306
View File
@@ -0,0 +1,306 @@
"""Instruction & answer generation (T1.4).
Builds (instruction, answer) pairs for 6 task categories over a synthesized
multivariate series:
* ``describe`` — per-channel statistics (JSON)
* ``anomaly`` — anomaly regions as JSON intervals aligned with labels
* ``root_cause`` — free-text root-cause hypothesis
* ``forecast`` — simple horizon forecast values (JSON)
* ``compare`` — compare channels (text)
* ``event`` — correlate a series change with an event (text)
Answers are either strict JSON (machine-checkable) or free text. The anomaly
answer's regions always reconstruct exactly the ground-truth ``labels``.
Evol-Instruct (:func:`evolve_instruction`) rephrases a base instruction to
diversify phrasings while preserving the core subject.
"""
from __future__ import annotations
import json
import random
import re
from typing import Any
import numpy as np
INSTRUCTION_CATEGORIES = (
"describe",
"anomaly",
"root_cause",
"forecast",
"compare",
"event",
)
# ─── instruction phrasing ──────────────────────────────────────────────────
_INSTRUCTION_TEMPLATES = {
"describe": [
"Summarize the recent behavior of the {names} time series.",
"Describe the overall trend and level of the {names} series.",
],
"anomaly": [
"Identify all anomaly intervals in the {names} series and report them as JSON regions.",
"Which time intervals are anomalous in the {names} series? Answer with JSON regions.",
],
"root_cause": [
"What is the likely root cause of the anomaly in the {names} series?",
"Given the {names} series, hypothesize a root cause for the observed anomaly.",
],
"forecast": [
"Forecast the next {horizon} steps of the {names} series.",
"Predict the {names} series for the next {horizon} steps.",
],
"compare": [
"Compare the behavior of the channels in the {names} series.",
"How do the channels in the {names} series differ from each other?",
],
"event": [
"Is there any event that explains the change in the {names} series around step {et}?",
"Relate the change in the {names} series near step {et} to a known event.",
],
}
def _channel_names(series_meta: list[dict[str, Any]]) -> str:
return ", ".join(m["name"] for m in series_meta)
def _format_instruction(category: str, series_meta, *, horizon=10, event=None) -> str:
tmpl = _INSTRUCTION_TEMPLATES[category]
names = _channel_names(series_meta)
fields = {"names": names, "horizon": horizon}
if event is not None:
fields["et"] = event["t"]
return tmpl[0].format(**fields)
# ─── answer builders ───────────────────────────────────────────────────────
def _json_str(obj) -> str:
return json.dumps(obj, ensure_ascii=False)
def _stats(series: np.ndarray, series_meta) -> list[dict[str, float]]:
out = []
for i, m in enumerate(series_meta):
col = series[:, i]
finite = col[np.isfinite(col)]
if finite.size == 0:
stats = {"name": m["name"], "mean": None, "std": None, "min": None, "max": None}
else:
stats = {
"name": m["name"],
"unit": m.get("unit", ""),
"mean": float(np.mean(finite)),
"std": float(np.std(finite)),
"min": float(np.min(finite)),
"max": float(np.max(finite)),
}
out.append(stats)
return out
def _regions_from_labels(labels: np.ndarray) -> list[dict[str, int]]:
"""Convert a 0/1 point-label array into half-open [start,end) regions."""
labels = np.asarray(labels).astype(int).squeeze()
regions = []
in_seg = False
start = 0
for i, v in enumerate(labels):
if v and not in_seg:
in_seg, start = True, i
elif not v and in_seg:
in_seg = False
regions.append({"start": int(start), "end": int(i)})
if in_seg:
regions.append({"start": int(start), "end": int(len(labels))})
return regions
def _naive_forecast(series: np.ndarray, horizon: int) -> list[list[float]]:
"""Drift-aware last-value+drift forecast per channel (baseline target).
Uses a simple linear extrapolation from the last finite window.
"""
out = []
T, C = series.shape
for c in range(C):
col = series[:, c]
finite_idx = np.where(np.isfinite(col))[0]
if finite_idx.size < 2:
last = float(col[finite_idx[-1]]) if finite_idx.size else 0.0
out.append([last] * horizon)
continue
window = col[finite_idx[-min(len(finite_idx), 16):]]
x = np.arange(len(window))
y = window
slope = float(np.polyfit(x, y, 1)[0]) if len(window) >= 2 else 0.0
last = float(window[-1])
out.append([float(last + slope * (k + 1)) for k in range(horizon)])
return out
# ─── public API ────────────────────────────────────────────────────────────
def build_instruction(
*,
category: str,
series: np.ndarray,
series_meta: list[dict[str, Any]],
labels: np.ndarray | None = None,
segments: list[dict[str, Any]] | None = None,
events: list[dict[str, Any]] | None = None,
seed: int | None = None,
horizon: int = 10,
) -> tuple[str, str]:
"""Build an ``(instruction, answer)`` pair for one task category.
Parameters
----------
category:
One of :data:`INSTRUCTION_CATEGORIES`.
series:
Array ``(T, C)``.
series_meta:
Per-channel metadata (name/unit/freq), length ``C``.
labels:
Point-wise 0/1 anomaly labels ``(T,)`` (required for anomaly/root_cause).
segments:
Anomaly segment metadata.
events:
List of event dicts (``{"t", "text"}``); used by the ``event`` category.
seed:
RNG seed (affects phrasing / template choice / root-cause wording).
horizon:
Forecast horizon (only used by ``forecast``).
"""
if category not in INSTRUCTION_CATEGORIES:
raise ValueError(f"unknown category {category!r}")
rng = random.Random(seed)
series = np.asarray(series, dtype=float)
if series.ndim == 1:
series = series[:, None]
event = (events or [None])[0]
instruction = _format_instruction(category, series_meta, horizon=horizon, event=event)
if category == "describe":
answer = _json_str({"stats": _stats(series, series_meta)})
elif category == "anomaly":
if labels is None:
raise ValueError("anomaly category requires labels")
regions = _regions_from_labels(labels)
answer = _json_str(
{
"anomaly_regions": regions,
"n_regions": len(regions),
}
)
elif category == "forecast":
fc = _naive_forecast(series, horizon)
answer = _json_str(
{
"forecast": fc,
"horizon": horizon,
"channels": [m["name"] for m in series_meta],
}
)
elif category == "compare":
stats = _stats(series, series_meta)
if len(stats) >= 2:
lines = [
f"{s['name']}: mean {s['mean']:.2f}{s.get('unit','')}, "
f"range [{s['min']:.2f}, {s['max']:.2f}]"
for s in stats
]
lo = min(stats, key=lambda s: s["mean"])
hi = max(stats, key=lambda s: s["mean"])
answer = (
"Per-channel summary:\n" + "\n".join(lines) + "\n"
f"{hi['name']} has the highest average level; "
f"{lo['name']} the lowest."
)
else:
answer = "Only one channel present; no cross-channel comparison."
elif category == "root_cause":
seg = (segments or [None])[0]
if seg is not None and seg.get("type") == "level_shift":
cause = "a sudden level shift"
elif seg is not None and seg.get("type") == "variance_change":
cause = "a variance expansion"
elif seg is not None and seg.get("type") == "drift":
cause = "a gradual drift"
else:
cause = "an anomaly"
cname = series_meta[0]["name"]
answer = (
f"The deviation in {cname} is most consistent with {cause}. "
f"A plausible root cause is a deployment or configuration change "
f"that altered the operating point of the service."
)
elif category == "event":
if event is None:
answer = "No known event corresponds to the observed change."
else:
answer = (
f"Yes — the change near step {event['t']} coincides with: "
f"{event['text']}. This event is the most likely explanation "
f"for the shift."
)
else: # pragma: no cover - guarded above
raise AssertionError(category)
return instruction, answer
# ─── Evol-Instruct ─────────────────────────────────────────────────────────
_REWRITE_RULES = [
("Describe", "Give a summary of"),
("Describe", "Briefly characterize"),
("Identify", "Point out"),
("Forecast", "Predict"),
("Predict", "Estimate the future of"),
("Compare", "Contrast"),
("series", "time series"),
("series", "signal"),
("anomaly", "outlier"),
("intervals", "regions"),
("the", "the observed"),
("which", "which of the"),
("Summarize", "Provide an overview of"),
]
def evolve_instruction(base: str, *, seed: int | None = None) -> str:
"""Rephrase a base instruction via a seed-controlled set of rewrite rules.
This is a lightweight, deterministic stand-in for Evol-Instruct: it applies
a random subset of (pattern, replacement) rules to diversify phrasing while
preserving the core subject words.
"""
rng = random.Random(seed)
out = base
# shuffle rules deterministically and apply a subset
rules = list(_REWRITE_RULES)
rng.shuffle(rules)
n = rng.randint(1, max(1, len(rules) // 2))
for pat, repl in rules[:n]:
out = re.sub(rf"\b{re.escape(pat)}\b", repl, out, count=1)
# occasional prefix to vary openings
if rng.random() < 0.3:
prefix = rng.choice(["Please ", "Kindly ", "Could you "])
out = prefix + out[0].lower() + out[1:]
return out