| |
| """ |
| Convert the Time-RA RATs-Uni (univariate) reasoning dataset into the parquet |
| format consumed by AnomSeer's veRL training/eval pipeline. |
| |
| Unlike AnomLLM (see ``anom.py``), the Time-RA univariate dataset does **not** |
| provide anomaly *intervals* — it provides, per univariate series: |
| |
| * ``Observation`` : the raw time series (list of floats, length 32/64/128) |
| * ``FigurePath`` : a rendered plot (``figures/{train,test}/{idx}.jpg``) |
| * ``ActionID`` : the anomaly class id (0-14, 0 == normal) |
| * ``Action`` : the human-readable class name |
| * ``Label`` : "Normal" / "Anomaly" |
| * ``Thought`` : an expert chain-of-thought explanation |
| |
| We therefore adapt AnomSeer to a **classification + reasoning** task (no |
| localization). Each output row matches the schema expected by |
| ``verl.utils.dataset.rl_dataset.RLHFDataset`` and is scored by the |
| ``timeseries_rats`` reward (``verl/utils/reward_score/rats.py``): |
| |
| data_source : "timeseries_rats" |
| prompt : [{"role": "user", "content": "<image>\\n ... <class> ..."}] |
| images : [{"bytes": <jpg bytes>, "path": <abs path>}] |
| ability : "time_series_anomaly_detection" |
| reward_model : {"style": "rule", "ground_truth": <canonical class name>} |
| extra_info : {category, source, split, anomaly_type, action_id, label, |
| series_length, image_path, expcot, numtext, index} |
| |
| The ``numtext`` field is the expert CoT and is **required** by TimerPO's |
| semantic-alignment term (``compute_hidden_states_of_hint`` in fsdp_workers.py). |
| |
| Usage |
| ----- |
| python multimodal_data_processing/rats_uni.py \ |
| --json_path /path/to/RATs40K/RATs-Uni-TSImage_Reason.json \ |
| --out_dir ./data/rats_uni_processed |
| |
| Produces ``train_full.parquet`` and ``test_full.parquet`` in ``--out_dir``. |
| """ |
|
|
| import os |
| import io |
| import json |
| import argparse |
| from typing import Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
| from PIL import Image |
|
|
|
|
| |
| |
| ANOMALY_DICT: Dict[int, Tuple[str, str]] = { |
| 0: ("Normal Sequence", "There are no abnormal situations in this time series."), |
| 1: ("Point Anomaly", "A single data point significantly deviates from the local or global pattern of the sequence."), |
| 2: ("Periodic Change Anomaly", "The original periodic pattern is disrupted, e.g. the period is broken or the amplitude becomes anomalous."), |
| 3: ("Trend Change Anomaly", "A sudden change in the long-term trend of the time series."), |
| 4: ("Change Point Anomaly", "Statistical properties (e.g. mean, variance) change abruptly at certain points."), |
| 5: ("Distributional Change Anomaly", "The statistical distribution of the time series changes significantly."), |
| 6: ("Amplitude Anomaly", "The amplitude of data points exceeds the normal upper and lower bounds."), |
| 7: ("Pattern Change Anomaly", "The pattern of the time series suddenly changes from one form to another."), |
| 8: ("Sparse Anomaly", "Isolated anomalous patterns occasionally appear in a long time series."), |
| 9: ("Repeated Value Anomaly", "Continuous or intermittent repeated values disrupt the normal fluctuation pattern."), |
| 10: ("Sudden Flatline Anomaly", "The time series suddenly becomes a flat line with no normal fluctuations."), |
| 11: ("Drift Anomaly", "The data gradually drifts away from the normal level."), |
| 12: ("Sudden Spike Anomaly", "The data suddenly spikes or drops within a short time and then returns to normal."), |
| 13: ("Continuous Segment Anomaly", "A continuous segment of data points deviates from the normal pattern."), |
| 14: ("Nonlinear Pattern Anomaly", "Nonlinear changes appear in the sequence, breaking the original linear rule."), |
| } |
|
|
| NAME_BY_ID = {i: name for i, (name, _) in ANOMALY_DICT.items()} |
| ID_BY_NAME = {name.lower(): i for i, (name, _) in ANOMALY_DICT.items()} |
|
|
| |
| SPLIT_MAP = {"TSAD_train": "train", "TSAD_test": "eval"} |
|
|
|
|
| def _build_taxonomy_block() -> str: |
| lines = [] |
| for i in range(len(ANOMALY_DICT)): |
| name, desc = ANOMALY_DICT[i] |
| lines.append(f"{i}: {name} — {desc}") |
| return "\n".join(lines) |
|
|
|
|
| _TAXONOMY_BLOCK = _build_taxonomy_block() |
| _CLASS_NAMES = ", ".join(NAME_BY_ID[i] for i in range(len(NAME_BY_ID))) |
|
|
|
|
| def build_prompt(length: int, source: str) -> str: |
| """Classification + reasoning prompt (no interval localization).""" |
| return ( |
| "<image>\n" |
| f"You are an expert in univariate time-series anomaly detection. The figure shows a " |
| f"single-channel time series of length {length} from the \"{source}\" domain.\n\n" |
| "Decide whether the series is normal or contains an anomaly. If it is anomalous, choose the " |
| "single most appropriate anomaly type from the following 15 categories " |
| "(format `id: name — description`):\n" |
| f"{_TAXONOMY_BLOCK}\n\n" |
| "Reason step by step inside <think>...</think> based on the visual shape of the series, " |
| "then output exactly one line with your final answer:\n" |
| "<class>one exact category name from the list above</class>\n" |
| "If the series is normal, use <class>Normal Sequence</class>." |
| ) |
|
|
|
|
| def _canonical_class(action: Optional[str], action_id: Optional[int]) -> Tuple[str, int]: |
| """Resolve the canonical (name, id) from the raw Action / ActionID fields. |
| |
| ActionID is treated as authoritative; Action is used only as a fallback. |
| """ |
| if isinstance(action_id, (int, np.integer)) and int(action_id) in NAME_BY_ID: |
| cid = int(action_id) |
| return NAME_BY_ID[cid], cid |
| if isinstance(action, str) and action.strip().lower() in ID_BY_NAME: |
| cid = ID_BY_NAME[action.strip().lower()] |
| return NAME_BY_ID[cid], cid |
| |
| return NAME_BY_ID[0], 0 |
|
|
|
|
| def _read_image_bytes(path: str) -> Optional[bytes]: |
| """Return raw image bytes, re-encoding to a clean RGB JPEG if needed.""" |
| if not os.path.isfile(path): |
| return None |
| try: |
| with open(path, "rb") as f: |
| raw = f.read() |
| |
| with Image.open(io.BytesIO(raw)) as im: |
| if im.mode == "RGB": |
| return raw |
| buf = io.BytesIO() |
| im.convert("RGB").save(buf, format="JPEG", quality=95) |
| return buf.getvalue() |
| except Exception as exc: |
| print(f"[WARN] failed to read image {path}: {exc}") |
| return None |
|
|
|
|
| |
| |
| |
| _IMG_EXT_FALLBACKS = (".jpg", ".png", ".jpeg") |
|
|
|
|
| def _resolve_figure_path(data_root: str, figure_path: str) -> str: |
| """FigurePath is stored relative to the dataset root (e.g. figures/train/0.jpg). |
| |
| Returns the first existing file, trying the stored path first and then the |
| same stem with a raster extension (handles ``.pdf`` FigurePaths).""" |
| base = figure_path if os.path.isabs(figure_path) else os.path.join(data_root, figure_path) |
| if os.path.isfile(base): |
| return base |
| stem, _ = os.path.splitext(base) |
| for ext in _IMG_EXT_FALLBACKS: |
| cand = stem + ext |
| if os.path.isfile(cand): |
| return cand |
| return base |
|
|
|
|
| def process_split(records: dict, split_name: str, data_root: str, |
| max_samples: Optional[int] = None) -> List[dict]: |
| rows: List[dict] = [] |
| n_skipped_none = 0 |
| n_skipped_img = 0 |
|
|
| |
| keys = sorted(records.keys(), key=lambda k: int(k) if str(k).isdigit() else k) |
| for out_idx, key in enumerate(keys): |
| if max_samples is not None and len(rows) >= max_samples: |
| break |
|
|
| entry = records[key] |
| if entry is None: |
| n_skipped_none += 1 |
| continue |
|
|
| obs = entry.get("Observation") or [] |
| length = len(obs) |
| source = entry.get("Source", "unknown") |
| figure_path = entry.get("FigurePath", "") |
| thought = entry.get("Thought", "") or "" |
| label = entry.get("Label", "Anomaly") |
|
|
| img_path = _resolve_figure_path(data_root, figure_path) |
| img_bytes = _read_image_bytes(img_path) |
| if img_bytes is None: |
| n_skipped_img += 1 |
| continue |
|
|
| class_name, class_id = _canonical_class(entry.get("Action"), entry.get("ActionID")) |
| prompt_text = build_prompt(length, source) |
|
|
| rows.append({ |
| "data_source": "timeseries_rats", |
| "prompt": [{"role": "user", "content": prompt_text}], |
| "images": [{"bytes": img_bytes, "path": img_path}], |
| "ability": "time_series_anomaly_detection", |
| "reward_model": {"style": "rule", "ground_truth": class_name}, |
| "extra_info": { |
| "index": int(key) if str(key).isdigit() else out_idx, |
| "category": class_name, |
| "source": source, |
| "split": split_name, |
| "anomaly_type": class_name, |
| "action_id": int(class_id), |
| "label": label, |
| "series_length": int(length), |
| "image_path": img_path, |
| "expcot": thought, |
| "numtext": thought, |
| }, |
| }) |
|
|
| print(f" [{split_name}] kept={len(rows)} " |
| f"skipped(null)={n_skipped_none} skipped(missing-image)={n_skipped_img}") |
| return rows |
|
|
|
|
| def write_parquet(rows: List[dict], path: str) -> None: |
| if not rows: |
| print(f" [skip] no rows for {path}") |
| return |
| df = pd.DataFrame(rows) |
| df.to_parquet(path, index=False, engine="pyarrow") |
| print(f" ✅ wrote {len(df)} rows -> {path}") |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser( |
| description="Convert Time-RA RATs-Uni JSON into AnomSeer parquet (classification + reasoning).") |
| parser.add_argument("--json_path", type=str, |
| default="/mnt/share01/sqk/datasets/RATs40K/RATs-Uni-TSImage_Reason.json", |
| help="Path to RATs-Uni-TSImage_Reason.json") |
| parser.add_argument("--data_root", type=str, default=None, |
| help="Dataset root that FigurePath is relative to " |
| "(defaults to the directory of --json_path).") |
| parser.add_argument("--out_dir", type=str, default="./data/rats_uni_processed", |
| help="Output directory for the parquet files.") |
| parser.add_argument("--max_samples", type=int, default=None, |
| help="Optional cap on samples per split (for quick tests).") |
| args = parser.parse_args() |
|
|
| data_root = args.data_root or os.path.dirname(os.path.abspath(args.json_path)) |
| os.makedirs(args.out_dir, exist_ok=True) |
|
|
| print(f"Reading {args.json_path}") |
| print(f"Figure root: {data_root}") |
| with open(args.json_path, "r") as f: |
| data = json.load(f) |
|
|
| for json_split, out_split in SPLIT_MAP.items(): |
| if json_split not in data: |
| print(f"[WARN] split '{json_split}' not found in JSON; skipping.") |
| continue |
| print(f"\nProcessing {json_split} -> {out_split}") |
| rows = process_split(data[json_split], out_split, data_root, args.max_samples) |
| fname = "train_full.parquet" if out_split == "train" else "test_full.parquet" |
| write_parquet(rows, os.path.join(args.out_dir, fname)) |
|
|
| print("\nDone.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|