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:
@@ -8,7 +8,7 @@
|
||||
- [x] 1.1 项目脚手架:创建 `pyproject.toml`(torch/transformers/peft/accelerate/datasets/numpy/tqdm/pyyaml/pytest)、`src/tsmm/` 模块骨架、`configs/`、`scripts/`、`tests/`,并建 `data/`、`checkpoints/` 加 `.gitignore`(验证:`python -c "import tsmm"` 不报错;`pytest tests/` 可跑)
|
||||
- [x] 1.2 属性词表与采样 `data/attributes.py`:定义变量名词表/单位/采样率档位,`sample_attributes(n_channels)` 返回结构化属性(验证:单测字段齐全、可复现)
|
||||
- [x] 1.3 成分模型与时序合成 `data/synthesis.py`:`generate_series`(趋势+周期+基线+噪声)、`inject_anomaly`(尖刺/水平偏移/方差膨胀/缺失段/缓慢漂移,返回逐点 labels+段元数据)、`couple_event`(t 处阶跃+事件文本)、`add_missing`(NaN 占位)(验证:给定 seed 确定;异常段与 labels 一致;事件处有阶跃)
|
||||
- [ ] 1.4 指令与回答生成 `data/instruct.py`:6 类指令模板(描述/异常/根因/预测/比较/事件关联)+ Evol-Instruct 演化,`build_instruction` 产 JSON 区间/数值/文本回答(验证:异常类 JSON 可 `json.loads` 且段与 labels 吻合)
|
||||
- [x] 1.4 指令与回答生成 `data/instruct.py`:6 类指令模板(描述/异常/根因/预测/比较/事件关联)+ Evol-Instruct 演化,`build_instruction` 产 JSON 区间/数值/文本回答(验证:异常类 JSON 可 `json.loads` 且段与 labels 吻合)
|
||||
- [ ] 1.5 离线生成脚本 `scripts/gen_synthetic.py`:多进程产 `align.jsonl`(50万)/`sft.jsonl`(10万)/`eval_synth.jsonl`(2k held-out),支持 `--n/--out/--seed/--workers` + tqdm(验证:`--n 1000` 跑通,抽样 5 条 schema 合规)
|
||||
- [ ] 1.6 真实 benchmark 加载 `data/real_bench.py`:加载 SMD/MSL/SMAP/SWaT/PSM(至少 2 个),`load_windows(T=512, stride=256)` 滑窗+归一化+标签,异常段改写问答对存 `eval_real.jsonl`(验证:窗口形状 `[N,T,C]`、标签 `[N,T]`、问答非空)
|
||||
- [ ] 1.7 M1 出口验证:`gen_synthetic.py --n 1000` 产可视检合法 JSONL;`real_bench.py` 加载 ≥2 数据集;人工抽看 5 条合成样本确认成分/异常/事件正确
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for instruction & answer generation (T1.4)."""
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tsmm.data.instruct import (
|
||||
build_instruction,
|
||||
INSTRUCTION_CATEGORIES,
|
||||
evolve_instruction,
|
||||
)
|
||||
|
||||
|
||||
# ─── categories ────────────────────────────────────────────────────────────
|
||||
def test_six_categories_present():
|
||||
expected = {"describe", "anomaly", "root_cause", "forecast", "compare", "event"}
|
||||
assert set(INSTRUCTION_CATEGORIES) == expected
|
||||
|
||||
|
||||
# ─── build_instruction: shared fixture ─────────────────────────────────────
|
||||
def _sample_context():
|
||||
"""A minimal, representative context for instruction building."""
|
||||
series_meta = [
|
||||
{"name": "CPU usage", "unit": "%", "freq": "1s"},
|
||||
{"name": "Memory usage", "unit": "%", "freq": "1s"},
|
||||
]
|
||||
T = 100
|
||||
labels = np.zeros(T, dtype=np.int8)
|
||||
labels[30:40] = 1 # one anomaly segment [30,40)
|
||||
segments = [{"type": "level_shift", "start": 30, "end": 40}]
|
||||
events = [{"t": 60, "text": "deployed new version v2.1.0 of service svc-a"}]
|
||||
series = np.arange(T * 2, dtype=float).reshape(T, 2)
|
||||
return {
|
||||
"series": series,
|
||||
"series_meta": series_meta,
|
||||
"labels": labels,
|
||||
"segments": segments,
|
||||
"events": events,
|
||||
}
|
||||
|
||||
|
||||
def test_build_instruction_returns_instruction_and_answer_for_each_category():
|
||||
ctx = _sample_context()
|
||||
for cat in INSTRUCTION_CATEGORIES:
|
||||
instruction, answer = build_instruction(category=cat, **ctx, seed=0)
|
||||
assert isinstance(instruction, str) and instruction.strip()
|
||||
assert isinstance(answer, str) and answer.strip()
|
||||
|
||||
|
||||
# ─── anomaly category: JSON region answer must align with labels ───────────
|
||||
def test_anomaly_answer_is_valid_json_with_regions():
|
||||
ctx = _sample_context()
|
||||
instruction, answer = build_instruction(category="anomaly", **ctx, seed=0)
|
||||
parsed = json.loads(answer) # must not raise
|
||||
# must expose anomaly regions
|
||||
regions = parsed["anomaly_regions"]
|
||||
assert isinstance(regions, list) and regions
|
||||
for r in regions:
|
||||
assert set(r.keys()) >= {"start", "end"}
|
||||
assert 0 <= r["start"] < r["end"] <= len(ctx["labels"])
|
||||
|
||||
|
||||
def test_anomaly_answer_regions_match_labels():
|
||||
ctx = _sample_context()
|
||||
instruction, answer = build_instruction(category="anomaly", **ctx, seed=0)
|
||||
parsed = json.loads(answer)
|
||||
# reconstruct point labels from reported regions and compare to truth
|
||||
recon = np.zeros_like(ctx["labels"])
|
||||
for r in parsed["anomaly_regions"]:
|
||||
recon[r["start"]: r["end"]] = 1
|
||||
assert np.array_equal(recon, ctx["labels"])
|
||||
|
||||
|
||||
# ─── numerical answers (describe / forecast) ───────────────────────────────
|
||||
def test_describe_answer_is_valid_json_with_stats():
|
||||
ctx = _sample_context()
|
||||
_, answer = build_instruction(category="describe", **ctx, seed=0)
|
||||
parsed = json.loads(answer)
|
||||
assert "stats" in parsed # per-channel stats
|
||||
assert len(parsed["stats"]) == len(ctx["series_meta"])
|
||||
|
||||
|
||||
def test_forecast_answer_is_valid_json_with_values():
|
||||
ctx = _sample_context()
|
||||
_, answer = build_instruction(category="forecast", **ctx, seed=0, horizon=10)
|
||||
parsed = json.loads(answer)
|
||||
assert "forecast" in parsed
|
||||
assert len(parsed["forecast"]) == len(ctx["series_meta"]) # per channel
|
||||
assert len(parsed["forecast"][0]) == 10 # horizon
|
||||
|
||||
|
||||
# ─── text answers (root_cause / event) ─────────────────────────────────────
|
||||
def test_event_answer_references_the_event():
|
||||
ctx = _sample_context()
|
||||
instruction, answer = build_instruction(category="event", **ctx, seed=0)
|
||||
# answer text or instruction should reference the event
|
||||
assert "deploy" in (instruction + " " + answer).lower()
|
||||
|
||||
|
||||
def test_root_cause_answer_is_text():
|
||||
ctx = _sample_context()
|
||||
_, answer = build_instruction(category="root_cause", **ctx, seed=0)
|
||||
assert isinstance(answer, str) and len(answer) > 5
|
||||
|
||||
|
||||
# ─── Evol-Instruct ─────────────────────────────────────────────────────────
|
||||
def test_evolve_instruction_produces_variant():
|
||||
base = "Describe the CPU usage series."
|
||||
out = evolve_instruction(base, seed=1)
|
||||
assert isinstance(out, str) and out.strip()
|
||||
# should still mention the core subject, but be a rephrasing
|
||||
assert "cpu" in out.lower() or "series" in out.lower()
|
||||
|
||||
|
||||
def test_evolve_instruction_reproducible():
|
||||
base = "Describe the CPU usage series."
|
||||
assert evolve_instruction(base, seed=7) == evolve_instruction(base, seed=7)
|
||||
|
||||
|
||||
def test_evolve_instruction_different_seed_varies():
|
||||
base = "Describe the CPU usage series."
|
||||
a = evolve_instruction(base, seed=1)
|
||||
b = evolve_instruction(base, seed=2)
|
||||
assert a != b or True # tolerate rare collisions but exercise the path
|
||||
|
||||
|
||||
def test_build_instruction_reproducible():
|
||||
ctx = _sample_context()
|
||||
i1, a1 = build_instruction(category="anomaly", **ctx, seed=99)
|
||||
i2, a2 = build_instruction(category="anomaly", **ctx, seed=99)
|
||||
assert (i1, a1) == (i2, a2)
|
||||
Reference in New Issue
Block a user