Files
NexQuant/rdagent/scenarios/data_science/debug/data.py
T

627 lines
24 KiB
Python
Raw Normal View History

2025-07-02 15:11:57 +08:00
import json
import os
import shutil
2025-02-12 16:36:46 +08:00
from collections import Counter, defaultdict
from pathlib import Path
2025-07-02 15:11:57 +08:00
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
import pandas as pd
from tqdm import tqdm
try:
import bson # pip install pymongo
except:
pass
class DataHandler:
"""Base DataHandler interface."""
def load(self, path) -> pd.DataFrame:
raise NotImplementedError
def dump(self, df: pd.DataFrame, path):
raise NotImplementedError
class GenericDataHandler(DataHandler):
"""
A generic data handler that automatically detects file type based on suffix
and uses the correct pandas method for load/dump.
"""
def load(self, path) -> pd.DataFrame:
path = Path(path)
suffix = path.suffix.lower()
if suffix == ".csv":
2025-02-06 18:01:15 +08:00
return pd.read_csv(path, encoding="utf-8")
elif suffix == ".pkl":
return pd.read_pickle(path)
elif suffix == ".parquet":
return pd.read_parquet(path)
elif suffix in [".h5", ".hdf", ".hdf5"]:
# Note: for HDF, you need a 'key' in read_hdf. If you expect a single key,
# you might do: pd.read_hdf(path, key='df') or something similar.
# Adjust as needed based on your HDF structure.
return pd.read_hdf(path, key="data")
elif suffix == ".jsonl":
# Read JSON Lines file
return pd.read_json(path, lines=True)
2025-07-02 15:11:57 +08:00
elif suffix == ".json":
# Not each json file is able to be converted to a DataFrame
try:
return pd.read_json(path, lines=False)
except:
return None
elif suffix == ".bson":
data = bson.decode_file_iter(open(path, "rb"))
df = pd.DataFrame(data)
return df
else:
raise ValueError(f"Unsupported file type: {suffix}")
2025-07-02 15:11:57 +08:00
def dump(self, df: pd.DataFrame | dict, path):
path = Path(path)
suffix = path.suffix.lower()
if suffix == ".csv":
2025-02-06 18:01:15 +08:00
df.to_csv(path, index=False, encoding="utf-8")
elif suffix == ".pkl":
df.to_pickle(path)
elif suffix == ".parquet":
df.to_parquet(path, index=True)
elif suffix in [".h5", ".hdf", ".hdf5"]:
# Similarly, you need a key for HDF.
df.to_hdf(path, key="data", mode="w")
elif suffix == ".jsonl":
# Save DataFrame to JSON Lines file
df.to_json(path, orient="records", lines=True)
2025-07-02 15:11:57 +08:00
elif suffix == ".json":
df.to_json(path, orient="records", lines=False)
elif suffix == ".bson":
data = df.to_dict(orient="records")
with open(path, "wb") as file:
# Write each record in the list to the BSON file
for record in data:
file.write(bson.BSON.encode(record))
else:
raise ValueError(f"Unsupported file type: {suffix}")
class DataReducer:
"""Base DataReducer interface."""
2025-07-02 15:11:57 +08:00
def __init__(self, min_frac=0.02, min_num=5):
self.min_frac = min_frac
self.min_num = min_num
self.sampled_files = []
def reduce(self, df: pd.DataFrame) -> pd.DataFrame:
raise NotImplementedError
class RandDataReducer(DataReducer):
"""
Example random sampler: ensures at least `min_num` rows
or at least `min_frac` fraction of the data (whichever is larger).
"""
def reduce(self, df: pd.DataFrame, frac: float = None) -> pd.DataFrame:
frac = max(self.min_frac, self.min_num / len(df)) if frac is None else frac
# print(f"Sampling {frac * 100:.2f}% of the data ({len(df)} rows)")
if frac >= 1:
return df
return df.sample(frac=frac, random_state=1)
2025-07-02 15:11:57 +08:00
class FolderReducer(DataReducer):
"""
Sample folder from a large number of folders.
"""
def reduce(self, array: list, frac: float = None) -> list:
frac = max(self.min_frac, self.min_num / len(array)) if frac is None else frac
if frac >= 1:
return array
train_items = [x for x in array if "train" in str(x)]
test_items = [x for x in array if "test" in str(x)]
# 至少保留一个 train 和一个 test
mandatory = []
if train_items:
mandatory.append(np.random.choice(train_items, size=1, replace=False)[0])
if test_items:
mandatory.append(np.random.choice(test_items, size=1, replace=False)[0])
mandatory.extend(np.random.choice(array, size=int(len(array) * frac) - len(mandatory), replace=False))
return mandatory
class FileReducer(DataReducer):
"""
Sample file from a large number of files, keep min_num of files for each folder.
"""
def reduce(self, files: list[Path]) -> list:
folder_dict = defaultdict(list)
for file in files:
folder_dict[file.parent].append(file)
2025-07-02 15:11:57 +08:00
sampled_files = []
for folder, folder_files in folder_dict.items():
n = min(max(int(len(folder_files) * self.min_frac), self.min_num), len(folder_files))
sampled_files.extend(np.random.choice(folder_files, size=n, replace=False))
return sampled_files
class FileKeepReducer(DataReducer):
"""
Sample file from a large number of files, keep min_num of files for each folder.
"""
def reduce(self, files: list[Path]) -> list:
folder_dict = defaultdict(list)
for file in files:
folder_dict[file.parent].append(file)
sampled_files = []
max_num = max(len(folder_files) for folder_files in folder_dict.values())
for folder, folder_files in folder_dict.items():
print(f"[INFO] Folder {folder} contains {len(folder_files)} files.")
if len(folder_files) < max_num * self.min_frac:
print(f"[INFO] Folder {folder} less than {max_num * self.min_frac} files.")
sampled_files.extend(folder_files)
continue
n = min(max(int(len(folder_files) * self.min_frac), self.min_num), len(folder_files))
sampled_files.extend(np.random.choice(folder_files, size=n, replace=False))
return sampled_files
class SingleFileReducer(DataReducer):
"""
Sample file from a large number of files, keep at least 1 file.
"""
def reduce(self, files: list[Path]) -> list:
n = min(max(int(len(files) * self.min_frac), 1), len(files))
return np.random.choice(files, size=n, replace=False)
class UniqueIDDataReducer(DataReducer):
def reduce(self, df: pd.DataFrame) -> pd.DataFrame:
2025-02-06 18:01:15 +08:00
if not len(df):
return df
2025-02-26 15:24:39 +08:00
2025-07-02 15:11:57 +08:00
random_reducer = RandDataReducer(self.min_frac, self.min_num)
2025-02-26 15:24:39 +08:00
if not isinstance(df, pd.DataFrame):
2025-07-02 15:11:57 +08:00
return random_reducer.reduce(df)
2025-02-26 15:24:39 +08:00
def is_valid_label(column):
if not isinstance(column.iloc[0], (int, float, str, tuple, frozenset, bytes, complex, type(None))):
return False
if not (0 < column.nunique() < df.shape[0] * 0.5):
return False
if pd.api.types.is_numeric_dtype(column) and all(isinstance(x, float) for x in column.dropna()):
return False
return True
2025-02-26 15:24:39 +08:00
label_col = df.iloc[:, -1]
if not is_valid_label(label_col) and df.shape[1] > 2:
label_col = df.iloc[:, 1]
if not is_valid_label(label_col):
2025-07-02 15:11:57 +08:00
return random_reducer.reduce(df)
2025-02-26 15:24:39 +08:00
unique_labels = label_col.unique()
unique_count = len(unique_labels)
print(f"Unique labels: {unique_count} / {df.shape[0]}")
2025-02-26 15:24:39 +08:00
sampled_rows = df.groupby(label_col, group_keys=False).apply(lambda x: x.sample(n=1, random_state=1))
frac = max(self.min_frac, self.min_num / len(df))
if int(len(df) * frac) < unique_count:
return sampled_rows.reset_index(drop=True)
remain_df = df.drop(index=sampled_rows.index)
remaining_frac = frac - unique_count / len(df)
2025-07-02 15:11:57 +08:00
remaining_sampled = random_reducer.reduce(remain_df, remaining_frac)
result_df = pd.concat([sampled_rows, remaining_sampled]).sort_index()
return result_df
2025-07-02 15:11:57 +08:00
class JsonReducer(DataReducer):
def extract_filename(self, item: Any) -> Optional[str]:
if isinstance(item, str):
return item
if isinstance(item, dict):
for key in ("file_name", "filename", "path", "file", "url"):
if key in item and isinstance(item[key], str):
return item[key]
for v in item.values():
if isinstance(v, str):
if "/" in v or re.search(r"\.\w{2,4}$", v):
return v
return None
def reduce(self, data: dict) -> dict:
"""
1. 找到最大列表
2. 随机采样并替换
"""
candidates: List[Tuple[Union[Dict, str, int, List], Union[str, int], List[Any]]] = []
self._find_all_lists(data, None, None, candidates)
for parent, key, lst in sorted(candidates, key=lambda x: len(x[2]), reverse=True):
sampled = self._sample_list(lst)
if isinstance(parent, dict):
parent[key] = sampled # type: ignore
else:
parent[key] = sampled # type: ignore # parent 是 listkey 是 index, list.__setitem__(key, sampled)
self.sampled_files.extend([self.extract_filename(i) for i in sampled])
break
if len(self.sampled_files) <= 0:
raise AssertionError("sampled_files must contain at least one file")
2025-07-02 15:11:57 +08:00
return data
def _find_all_lists(
self,
current: Any,
parent: Union[Dict, List, None],
key: Union[str, int, None],
out: List[Tuple[Union[Dict, List], Union[str, int], List[Any]]],
) -> None:
"""
out => (parent_container, key_or_index, the_list)。
"""
if isinstance(current, dict):
for k, v in current.items():
if isinstance(v, list):
out.append((current, k, v))
self._find_all_lists(v, current, k, out)
elif isinstance(v, (dict, list)):
self._find_all_lists(v, current, k, out)
elif isinstance(current, list):
if parent is not None and key is not None:
out.append((parent, key, current))
for idx, item in enumerate(current):
if isinstance(item, (dict, list)):
self._find_all_lists(item, current, idx, out)
def _sample_list(self, lst: List[Any]) -> List[Any]:
target = max(self.min_num, int(len(lst) * self.min_frac))
if target >= len(lst):
return lst[:]
return np.random.choice(lst, size=target, replace=False)
class DataSampler:
"""Base DataSampler interface."""
def __init__(self, data_folder, sample_folder, reducer):
self.data_folder = data_folder
self.sample_folder = sample_folder
self.data_reducer = reducer
self.included_extensions = {".csv", ".pkl", ".parquet", ".h5", ".hdf", ".hdf5", ".jsonl", ".bson"}
self.data_handler = GenericDataHandler()
def sample(self) -> None:
raise NotImplementedError
class DefaultSampler(DataSampler):
def sample(self) -> None:
# Traverse the folder and exclude specific file types, without json currently
files_to_process = [file for file in self.data_folder.rglob("*") if file.is_file()]
file_types_count = count_files_in_folder(files_to_process)
sample_json = False
if isinstance(self.data_reducer, JsonReducer):
self.included_extensions.add(".json")
sample_json = True
skip_subfolder_data = any(
f.is_file() and f.suffix in self.included_extensions
for f in self.data_folder.iterdir()
if f.name.startswith(("train", "test"))
)
processed_files = []
sample_used_file_names = set()
has_id_col = False
for file_path in tqdm(files_to_process, desc="Processing data", unit="file"):
sampled_file_path = self.sample_folder / file_path.relative_to(self.data_folder)
if sampled_file_path.exists():
continue
if file_path.suffix.lower() not in self.included_extensions:
continue
if skip_subfolder_data and file_path.parent != self.data_folder:
continue # bypass files in subfolders
sampled_file_path.parent.mkdir(parents=True, exist_ok=True)
# Load the original data
if sample_json:
if file_path.suffix.lower() == ".json":
data = json.load(file_path.open())
data_sampled = self.data_reducer.reduce(data)
sample_used_file_names = [file_path.parent / i for i in self.data_reducer.sampled_files]
print("sample_used_file_names", len(sample_used_file_names))
else:
df = self.data_handler.load(file_path)
if df is None:
continue
# Create a sampled subset
df_sampled = self.data_reducer.reduce(df)
processed_files.append(file_path)
# Dump the sampled data
try:
self.data_handler.dump(df_sampled, sampled_file_path)
# Extract possible file references from the sampled data
if "submission" in file_path.stem:
continue # Skip submission files
for col in df_sampled.columns:
if "id" in col:
has_id_col = True
sample_used_file_names.extend([df_sampled[col].astype(str).unique()])
continue
for col in df_sampled.columns:
sample_used_file_names.extend([df_sampled[col].astype(str).unique()])
except Exception as e:
print(f"Error processing {file_path}: {e}")
continue
# Process non-data files
subfolder_dict = {}
global_groups = defaultdict(list)
for file_path in files_to_process:
if file_path in processed_files:
continue # Already handled above
rel_dir = file_path.relative_to(self.data_folder).parts[0]
subfolder_dict.setdefault(rel_dir, []).append(file_path)
global_groups[file_path.stem].append(Path(file_path))
# For each subfolder, decide which files to copy
selected_groups = []
extra_tag = [".txt", ".json"]
for rel_dir, file_list in tqdm(subfolder_dict.items(), desc="Processing files", unit="file"):
used_files = []
not_used_files = []
extra_files = []
# Check if each file is in the "used" list
for fp in file_list:
if (
str(fp.name) in sample_used_file_names
or str(fp.stem) in sample_used_file_names
2025-07-19 18:35:24 +08:00
or fp in sample_used_file_names
2025-07-02 15:11:57 +08:00
):
used_files.append(fp)
else:
for tag in extra_tag:
if file_types_count.get(tag, 1000) < 100 and fp.suffix.lower() == tag:
extra_files.append(fp)
not_used_files.append(fp)
# Directly copy used files
for uf in used_files:
copy_file(uf, self.sample_folder, self.data_folder)
# If no files are used, randomly sample files to keep the folder from being empty
if len(used_files) == 0:
if len(file_list) <= self.data_reducer.min_num:
num_to_keep = len(file_list)
else:
num_to_keep = max(int(len(file_list) * self.data_reducer.min_frac), self.data_reducer.min_num)
# Use a greedy strategy to select groups so that the total number of files is as close as possible to num_to_keep
total_files = 0
np.random.shuffle(not_used_files)
for nf in not_used_files:
if total_files > num_to_keep:
break
if nf.stem in selected_groups:
total_files += 1
else:
selected_groups.append(nf.stem)
total_files += 1
print(f"Sampling {num_to_keep} files without label from {total_files} files in {rel_dir}")
# Flatten the selected groups into a single list of files
sampled_not_used = [
nf for group, value in global_groups.items() if group in selected_groups for nf in value
]
# Copy the selected files to the target directory (all files with the same base name will be copied)
for nf in sampled_not_used:
# Construct the target path based on the relative path of nf from data_folder
sampled_file_path = self.sample_folder / nf.relative_to(self.data_folder)
if sampled_file_path.exists():
continue
sampled_file_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(nf, sampled_file_path)
# Copy extra files
print(f"Copying {len(extra_files)} extra files")
for uf in extra_files:
copy_file(uf, self.sample_folder, self.data_folder)
final_files_count = sum(1 for _ in self.sample_folder.rglob("*") if _.is_file())
print(
f"[INFO] After sampling, the sample folder `{self.sample_folder}` contains {final_files_count} files in total."
)
class FolderSampler(DataSampler):
"""
Sample data from a large number of folders.
"""
def sample(self) -> None:
sample_used_file_names = []
current_level = [d for d in self.data_folder.iterdir() if d.is_dir()]
last_count = 0
subdirs = []
sample_dirs = []
sample_files = []
extra_files = [d for d in self.data_folder.iterdir() if d.is_file()]
level = 1
while current_level:
subdirs = [d for current_dir in current_level for d in current_dir.iterdir() if d.is_dir()]
subdirs_names = [d.name for d in subdirs]
extra_files.extend([d for current_dir in current_level for d in current_dir.iterdir() if d.is_file()])
if not subdirs:
print("current_level", len(current_level))
subfiles = [d for current_dir in current_level for d in current_dir.iterdir() if d.is_file()]
sample_files = self.data_reducer.reduce(subfiles)
extra_files = list(set(extra_files) - set(subfiles))
print(f"sample {len(sample_files)} files from {len(subfiles)}")
break
print(
f"subdirs count: {len(set(subdirs_names))}, last_count: {last_count}, subdirs[0]: {subdirs[0]}, sample_used_file_names count: {len(set(sample_used_file_names))}"
)
if sample_used_file_names and set(sample_used_file_names).issubset(set(subdirs_names)):
sample_dirs = [d for d in subdirs if d.name in sample_used_file_names]
print(f"sample {len(sample_dirs)} folders from {len(subdirs)}")
break
if len(subdirs_names) > 100 or (last_count and 1 < len(sample_dirs) < last_count):
sample_dirs = self.data_reducer.reduce(subdirs)
print(f"sample {len(sample_dirs)} folders from {len(subdirs)}")
break
last_count = len(set(subdirs_names))
current_level = subdirs
level += 1
print(
f"[INFO] After sampling, the sample folder `{self.sample_folder}` contains extra_files {len(extra_files)} folders in total."
)
for i in sample_dirs:
copy_folder(i, self.sample_folder, self.data_folder)
for i in sample_files:
copy_file(i, self.sample_folder, self.data_folder)
for i in set(extra_files):
copy_file(i, self.sample_folder, self.data_folder)
class SingleFilePerFolderSampler(DataSampler):
"""
2025-07-02 15:11:57 +08:00
For each leaf (final) subfolder under data_folder, keep exactly one file (randomly chosen).
Files in non-leaf folders are copied unchanged.
"""
2025-07-02 15:11:57 +08:00
def sample(self) -> None:
data_folder = Path(self.data_folder)
sample_folder = Path(self.sample_folder)
# Find all leaf directories (no subdirectories)
leaf_dirs = [Path(root) for root, dirs, _ in os.walk(data_folder) if not dirs]
print(f"Found {len(leaf_dirs)} leaf directories")
# Sample one file per leaf directory
for leaf in tqdm(leaf_dirs, desc="Processing files", unit="file"):
files = [f for f in leaf.iterdir() if f.is_file()]
if not files:
continue
chosen = self.data_reducer.reduce(files)
for f in chosen:
copy_file(f, sample_folder, data_folder)
# Copy all files in non-leaf directories
# i.e. any file whose parent is not a leaf dir
# Copy all files in non-leaf directories
for root, _, files in os.walk(data_folder):
current_dir = Path(root)
if current_dir in leaf_dirs:
continue
for fname in files:
file_path = current_dir / fname
copy_file(file_path, sample_folder, data_folder)
total = sum(1 for _ in sample_folder.rglob("*") if _.is_file())
print(f"[INFO] SingleFilePerFolderSampler: copied {total} files to {sample_folder}")
2025-02-14 19:16:28 +08:00
def copy_file(src_fp, target_folder, data_folder):
"""
Construct the target file path based on the file's relative location from data_folder,
then copy the file if it doesn't already exist.
"""
target_fp = target_folder / src_fp.relative_to(data_folder)
if not target_fp.exists():
target_fp.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(src_fp, target_fp)
2025-07-02 15:11:57 +08:00
def copy_folder(src_fp, target_folder, data_folder):
"""
Copy a folder recursively.
"""
target_fp = target_folder / src_fp.relative_to(data_folder)
if not target_fp.exists():
target_fp.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_fp, target_fp)
def count_files_in_folder(files_to_process):
"""
Count the number of each file type in a folder, including files in subfolders.
"""
total_files_count = len(files_to_process)
print(f"[INFO] Original dataset folder has {total_files_count} files in total (including subfolders).")
file_types_count = Counter(file.suffix.lower() for file in files_to_process)
print("File type counts:")
for file_type, count in file_types_count.items():
print(f"{file_type}: {count}")
return file_types_count
def map_competition(competition: str) -> tuple[DataReducer, DataSampler]:
cls_map = {
"google-research-identify-contrails-reduce-global-warming": (FolderReducer, FolderSampler),
"smartphone-decimeter-2022": (FolderReducer, FolderSampler),
"herbarium-2020-fgvc7": (SingleFileReducer, SingleFilePerFolderSampler),
"herbarium-2021-fgvc8": (SingleFileReducer, SingleFilePerFolderSampler),
"herbarium-2022-fgvc9": (SingleFileReducer, SingleFilePerFolderSampler),
"vesuvius-challenge-ink-detection": (FileReducer, FolderSampler),
"3d-object-detection-for-autonomous-vehicles": (FileKeepReducer, FolderSampler),
}
return cls_map.get(competition, (UniqueIDDataReducer, DefaultSampler))
def create_debug_data(
competition: str,
2025-06-18 14:35:45 +08:00
dataset_path: str | Path,
min_frac=0.01,
min_num=5,
sample_path=None,
):
"""
Reads the original data file, creates a reduced sample,
and renames/moves files for easier debugging.
Automatically detects file type (csv, pkl, parquet, hdf, etc.).
"""
if sample_path is None:
2025-07-02 15:11:57 +08:00
sample_path = Path(dataset_path) / "sample"
# Prepare data handler and reducer
2025-07-02 15:11:57 +08:00
reduce_method, sample_method = map_competition(competition)
data_reducer = reduce_method(min_frac=min_frac, min_num=min_num)
sampler = sample_method(Path(dataset_path) / competition, Path(sample_path) / competition, data_reducer)
print(f"processing {competition}, sample_method: {sample_method}, reduce_method: {reduce_method}")
sampler.sample()