# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Dataset format detection: Alpaca/ShareGPT/ChatML, multimodal/VLM structures, heuristic column mapping.""" import re def _keyword_in_column(keyword: str, col_name: str) -> bool: """Word-boundary keyword match to avoid false positives like 'pic' in 'topic'.""" return re.search(r"\b" + re.escape(keyword) + r"\b", col_name, re.IGNORECASE) is not None CONVERSATION_COLUMNS = ("messages", "conversations", "texts") _CHATML_KEYS = frozenset({"role", "content"}) _SHAREGPT_KEYS = frozenset({"from", "value"}) _TRACE_SUFFIXES = ("__trace", "_trace") def _sample_dataset_rows(dataset, limit: int = 100) -> list[dict]: try: total = min(len(dataset), limit) return [dataset[index] for index in range(total)] except Exception: rows = [] try: for index, row in enumerate(dataset): if index >= limit: break rows.append(row) except Exception: return [] return rows def _get_dataset_column_names(dataset, sample: dict) -> list[str]: column_names = getattr(dataset, "column_names", None) if isinstance(column_names, list): return [str(column) for column in column_names] return [str(column) for column in sample.keys()] def _is_trace_conversation_name(column_name: str) -> bool: return column_name.lower().endswith(_TRACE_SUFFIXES) def _inspect_conversation_column(rows: list[dict], column_name: str) -> dict | None: turn_keys: set[str] = set() has_chatml = False has_sharegpt = False for row in rows: if not isinstance(row, dict) or column_name not in row: continue chat_data = row[column_name] if not isinstance(chat_data, list) or len(chat_data) == 0: continue for turn in chat_data: if not isinstance(turn, dict): continue keys = {str(key) for key in turn.keys()} turn_keys.update(keys) if _SHAREGPT_KEYS.issubset(keys): has_sharegpt = True if _CHATML_KEYS.issubset(keys): has_chatml = True if has_sharegpt: return { "format": "sharegpt", "chat_column": column_name, "needs_standardization": True, "sample_keys": sorted(turn_keys), } if has_chatml: return { "format": "chatml", "chat_column": column_name, "needs_standardization": False, "sample_keys": sorted(turn_keys), } if turn_keys: return { "format": "unknown", "chat_column": column_name, "needs_standardization": None, "sample_keys": sorted(turn_keys), } return None def _detect_conversation_column(rows: list[dict], column_names: list[str]) -> dict | None: column_name_set = set(column_names) unknown_exact = None for column_name in CONVERSATION_COLUMNS: if column_name not in column_name_set: continue inspected = _inspect_conversation_column(rows, column_name) if inspected and inspected["format"] in {"sharegpt", "chatml"}: return inspected if inspected and unknown_exact is None: unknown_exact = inspected structural_candidates = [] for column_name in column_names: if column_name in CONVERSATION_COLUMNS: continue inspected = _inspect_conversation_column(rows, column_name) if inspected and inspected["format"] in {"sharegpt", "chatml"}: structural_candidates.append(inspected) trace_candidates = [ candidate for candidate in structural_candidates if _is_trace_conversation_name(candidate["chat_column"]) ] if len(trace_candidates) == 1: return trace_candidates[0] if len(trace_candidates) > 1: return unknown_exact if len(structural_candidates) == 1: return structural_candidates[0] if unknown_exact is not None: return unknown_exact return None def detect_dataset_format(dataset): """Detect dataset format by inspecting structure. Returns: dict: { "format": "alpaca" | "sharegpt" | "chatml" | "unknown", "chat_column": str | None, "needs_standardization": bool, "sample_keys": list of keys found in messages (for debugging) } """ sample_rows = _sample_dataset_rows(dataset) if not sample_rows: return { "format": "unknown", "chat_column": None, "needs_standardization": None, "sample_keys": [], } column_names = _get_dataset_column_names(dataset, sample_rows[0]) column_name_set = set(column_names) # Alpaca alpaca_columns = {"instruction", "output"} if alpaca_columns.issubset(column_name_set): return { "format": "alpaca", "chat_column": None, "needs_standardization": False, "sample_keys": [], } conversation = _detect_conversation_column(sample_rows, column_names) if conversation: return conversation return { "format": "unknown", "chat_column": None, "needs_standardization": None, "sample_keys": [], } def detect_custom_format_heuristic(dataset): """Detection with priority scoring. Strategy for ambiguous keywords like 'task': 1. Detect assistant first (unambiguous) 2. Detect user using high-priority keywords first 3. Check REMAINING columns for system keywords (including 'task') 4. Only if no system match, use 'task' as fallback user """ sample = next(iter(dataset)) all_columns = list(sample.keys()) mapping = {} assistant_words = [ "output", "answer", "response", "assistant", "completion", "expected", "recommendation", "reply", "result", "target", "solution", "explanation", "solve", ] user_words_high_priority = [ "input", "question", "query", "prompt", "instruction", "request", "snippet", "user", "text", "problem", "exercise", ] user_words_low_priority = ["task"] # Ambiguous - can be user OR system user_words = user_words_high_priority + user_words_low_priority system_words = [ "system", "context", "description", "persona", "role", "template", "task", # also a system keyword ] # Metadata columns to ignore. metadata_exact_match = { "id", "idx", "index", "key", "timestamp", "date", "metadata", "source", "kind", "type", "category", "score", "label", "tag", "inference_mode", } metadata_prefix_patterns = [ "problem_type", "problem_source", "generation_model", "pass_rate", ] priority_patterns = { "generated": 100, "gen_": 90, "model_": 80, "predicted": 70, "completion": 60, } def has_keyword(col_name, keywords): """True if any keyword appears in the column name.""" col_lower = col_name.lower() col_normalized = col_lower.replace("_", "").replace("-", "").replace(" ", "") for keyword in keywords: if keyword in col_lower or keyword in col_normalized: return True return False def is_metadata(col_name): """True if the column is likely metadata.""" col_lower = col_name.lower() if col_lower in metadata_exact_match: return True if col_lower in metadata_prefix_patterns: return True for pattern in metadata_prefix_patterns: if col_lower.startswith(pattern.split("_")[0] + "_") and col_lower != pattern: if "_" in col_lower: prefix = col_lower.split("_")[0] if prefix in ["generation", "pass", "inference"]: return True if len(col_lower) <= 2 and not col_lower in ["qa", "q", "a"]: return True return False def get_priority_score(col_name): """Priority score from column-name patterns.""" col_lower = col_name.lower() score = 0 for pattern, pattern_score in priority_patterns.items(): if pattern in col_lower: score += pattern_score return score def get_content_length(col_name): """Average content length for this column.""" try: if col_name in sample and sample[col_name]: content = str(sample[col_name]) return len(content) return 0 except: return 0 def score_column(col_name, keywords, role_type, num_candidates): """Score how likely a column is to be a given role.""" if not has_keyword(col_name, keywords): return 0 score = 0 score += 10 # Penalize ambiguous "task" so other user columns win. if role_type == "user": col_lower = col_name.lower() if "task" in col_lower and not any(kw in col_lower for kw in user_words_high_priority): score -= 15 priority_bonus = get_priority_score(col_name) score += priority_bonus if role_type in ["assistant", "user"]: avg_length = get_content_length(col_name) if num_candidates > 1: if avg_length > 1000: score += 50 elif avg_length > 200: score += 30 elif avg_length > 50: score += 10 elif avg_length < 50: score -= 20 else: if avg_length > 1000: score += 50 elif avg_length > 200: score += 30 elif avg_length > 50: score += 10 return score content_columns = [col for col in all_columns if not is_metadata(col)] assistant_potential = [col for col in content_columns if has_keyword(col, assistant_words)] user_potential = [col for col in content_columns if has_keyword(col, user_words)] # STEP 1: best ASSISTANT column assistant_candidates = [] for col in assistant_potential: score = score_column(col, assistant_words, "assistant", len(assistant_potential)) if score > 0: assistant_candidates.append((col, score)) if assistant_candidates: assistant_candidates.sort(key = lambda x: x[1], reverse = True) assistant_col = assistant_candidates[0][0] mapping[assistant_col] = "assistant" else: assistant_col = None # STEP 2: best USER column (penalizing ambiguous keywords) user_candidates = [] for col in user_potential: if col == assistant_col: continue score = score_column(col, user_words, "user", len(user_potential)) if score > 0: user_candidates.append((col, score)) if user_candidates: user_candidates.sort(key = lambda x: x[1], reverse = True) user_col = user_candidates[0][0] mapping[user_col] = "user" else: user_col = None # STEP 3: check remaining columns for SYSTEM matches remaining_columns = [col for col in content_columns if col not in mapping] system_col = None for col in remaining_columns: if has_keyword(col, system_words): mapping[col] = "system" system_col = col break # STEP 4: handle any additional remaining columns if system_col: remaining_columns = [col for col in remaining_columns if col != system_col] if len(remaining_columns) >= 1: remaining_col = remaining_columns[0] # No strong keyword match: decide by what's missing. if not has_keyword(remaining_col, user_words + assistant_words): mapping[remaining_col] = "system" elif user_col is None: mapping[remaining_col] = "user" else: mapping[remaining_col] = "system" # Ensure at least user + assistant. has_user = any(role == "user" for role in mapping.values()) has_assistant = any(role == "assistant" for role in mapping.values()) if not has_user and len(remaining_columns) > 0: for col in remaining_columns: if col not in mapping: mapping[col] = "user" has_user = True break if has_user and has_assistant: return mapping return None def detect_multimodal_dataset(dataset): """Detect multimodal data (images and/or audio) in a dataset. Two passes per modality: column-name keyword heuristic, then value-type inspection. Returns a dict with is_image/is_audio flags, detected columns, modality types, and detected audio/text/speaker columns. """ sample = next(iter(dataset)) column_names = list(sample.keys()) image_keywords = [ "image", "img", "pixel", "jpg", "jpeg", "png", "webp", "bmp", "gif", "tiff", "svg", "photo", "pic", "picture", "visual", "file_name", "filename", ] audio_keywords = ["audio", "speech", "wav", "waveform", "sound"] multimodal_columns = [] audio_columns = [] modality_types = set() # ── Image detection ───────────────────────────────────── # Pass 1: column-name heuristic (word-boundary match) for col_name in column_names: for keyword in image_keywords: if _keyword_in_column(keyword, col_name): multimodal_columns.append(col_name) modality_types.add(keyword) break # Pass 2: inspect actual values already_detected = set(multimodal_columns) for col_name in column_names: if col_name in already_detected: continue value = sample[col_name] if _is_image_value(value): multimodal_columns.append(col_name) modality_types.add("image") # ── Audio detection ───────────────────────────────────── # Pass 1: column-name heuristic (word-boundary match) for col_name in column_names: for keyword in audio_keywords: if _keyword_in_column(keyword, col_name): audio_columns.append(col_name) modality_types.add("audio") break # Pass 2: inspect actual values (catches non-obvious column names) already_audio = set(audio_columns) for col_name in column_names: if col_name in already_audio: continue value = sample[col_name] if _is_audio_value(value): audio_columns.append(col_name) modality_types.add("audio") # Drop audio columns from the image list (a {"bytes","path"} audio column # can match _is_image_value). if audio_columns: audio_set = set(audio_columns) multimodal_columns = [c for c in multimodal_columns if c not in audio_set] # Text column for audio datasets. detected_text_col = None if audio_columns: text_keywords = ["text", "sentence", "transcript", "transcription", "label"] for col_name in column_names: if col_name.lower() in text_keywords: detected_text_col = col_name break is_audio = len(audio_columns) > 0 # speaker_id column for TTS datasets (CSM, Orpheus, Spark) detected_speaker_col = None if audio_columns: speaker_keywords = ["source", "speaker", "speaker_id"] for col_name in column_names: if col_name.lower() in speaker_keywords: detected_speaker_col = col_name break return { "is_image": len(multimodal_columns) > 0, "multimodal_columns": multimodal_columns, "modality_types": list(modality_types), "is_audio": is_audio, "audio_columns": audio_columns, "detected_audio_column": audio_columns[0] if audio_columns else None, "detected_text_column": detected_text_col, "detected_speaker_column": detected_speaker_col, } def _is_image_value(value) -> bool: """Check if a single sample value looks like image data.""" if value is None: return False try: from PIL.Image import Image as PILImage if isinstance(value, PILImage): return True except ImportError: pass # HF Image feature: decoded as PIL, or {"bytes", "path"} when undecoded. # Exclude audio dicts (decoded audio has "array" + "sampling_rate"). if isinstance(value, dict): if "array" in value and "sampling_rate" in value: return False # audio, not image if "bytes" in value and "path" in value: # Use path extension to exclude audio files. path = value.get("path") or "" if isinstance(path, str) and any( path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS ): return False return True if isinstance(value, (bytes, bytearray)): return _has_image_header(value) # String that looks like an image file path or URL. _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg") if isinstance(value, str) and len(value) < 1000: lower = value.strip().lower() if lower.startswith(("http://", "https://")) and any( lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS ): return True if any(lower.endswith(ext) for ext in _IMAGE_EXTS): return True return False _AUDIO_EXTENSIONS = ( ".wav", ".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wma", ".webm", ) def _is_audio_value(value) -> bool: """Check if a single sample value looks like audio data.""" if value is None: return False # HF Audio feature: decoded -> {"array", "sampling_rate"}; undecoded -> {"bytes", "path"}. if isinstance(value, dict): if "array" in value and "sampling_rate" in value: return True if "bytes" in value or "path" in value: path = value.get("path") or "" if isinstance(path, str) and any( path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS ): return True return False def _has_image_header(data: bytes) -> bool: """Quick magic-byte check for common image formats.""" if len(data) < 4: return False if data[:2] == b"\xff\xd8": # JPEG return True if data[:4] == b"\x89PNG": # PNG return True if data[:3] == b"GIF": # GIF return True if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP": # WebP return True if data[:2] == b"BM": # BMP return True return False def detect_vlm_dataset_structure(dataset): """Detect which VLM dataset shape this is: - Standard VLM messages (image objects in content) - Llava format (image indices + separate images column) - Simple format needing conversion (image + text columns) """ try: sample = next(iter(dataset)) except StopIteration: return { "format": "unknown", "needs_conversion": None, "image_column": None, "text_column": None, "messages_column": None, } column_names = set(sample.keys()) if "messages" in column_names: messages = sample["messages"] if messages and len(messages) > 0: first_msg = messages[0] if "content" in first_msg: content = first_msg["content"] if isinstance(content, list) and len(content) > 0: if isinstance(content[0], dict) and "type" in content[0]: # Llava format? has_index = any( "index" in item for item in content if isinstance(item, dict) ) has_images_column = "images" in column_names if has_index and has_images_column: return { "format": "vlm_messages_llava", "needs_conversion": True, "messages_column": "messages", "image_column": "images", "text_column": None, } # Standard VLM format has_image = any( "image" in item for item in content if isinstance(item, dict) ) if has_image: return { "format": "vlm_messages", "needs_conversion": False, "messages_column": "messages", "image_column": None, "text_column": None, } # ShareGPT/ChatML conversations with placeholder + companion # image column (e.g. Lin-Chen/ShareGPT4V, LLaVA-style datasets) for chat_col in ("conversations", "messages"): if chat_col not in column_names: continue chat_data = sample[chat_col] if not isinstance(chat_data, list) or len(chat_data) == 0: continue first_msg = chat_data[0] if not isinstance(first_msg, dict): continue # ShareGPT (from/value) or ChatML (role/content). msg_text = first_msg.get("value") or first_msg.get("content") if not isinstance(msg_text, str): continue has_image_placeholder = any( "" in str(m.get("value", "") or m.get("content", "")) for m in chat_data if isinstance(m, dict) ) if not has_image_placeholder: continue # Find companion image column. image_col = None for col in column_names: if col == chat_col: continue if _keyword_in_column("image", col) or _keyword_in_column("img", col): image_col = col break if image_col: return { "format": "sharegpt_with_images", "needs_conversion": True, "image_column": image_col, "text_column": None, "messages_column": chat_col, } # Find image and text columns, filtering out metadata patterns metadata_patterns = { "suffixes": [ "_id", "_url", "_name", "_filename", "_uri", "_link", "_key", "_index", ], "prefixes": [ "id_", "url_", "name_", "filename_", "uri_", "link_", "key_", "index_", ], } image_keywords = [ "image", "img", "photo", "picture", "pic", "visual", "scan", "file_name", "filename", ] text_keywords = [ "text", "caption", "captions", "description", "answer", "output", "response", "label", ] def is_metadata_column(col_name): """True if the column name looks like metadata.""" col_lower = col_name.lower() if any(col_lower.endswith(suffix) for suffix in metadata_patterns["suffixes"]): return True if any(col_lower.startswith(prefix) for prefix in metadata_patterns["prefixes"]): return True return False def _score_image_candidate(col, sample_value): """Score a candidate image column by how resolvable its value is.""" # PIL Image (already loaded) -> highest. if hasattr(sample_value, "size") and hasattr(sample_value, "mode"): return 100 # HF Image feature dict. if isinstance(sample_value, dict) and ("bytes" in sample_value or "path" in sample_value): return 75 if isinstance(sample_value, str): if sample_value.startswith(("http://", "https://")): # URL return 70 if not is_metadata_column(col) else 55 if is_metadata_column(col): # bare file path return 30 return 50 return 0 def _probe_image_candidate(col, sample_value): """Probe whether an image candidate is reachable (True unless definitely broken).""" import os # PIL / dict — already loaded. if not isinstance(sample_value, str): return True # Local file — check it exists. if not sample_value.startswith(("http://", "https://")): return os.path.exists(sample_value) # bare filenames return False, that's OK # URL — quick HEAD with short timeout. try: import urllib.request req = urllib.request.Request(sample_value, method = "HEAD") resp = urllib.request.urlopen(req, timeout = 3) return resp.status < 400 except Exception: return False def find_image_column(): """Find image column by keyword match + value-based fallback, probing for one that works.""" candidates = [] # Pass 1: keyword-matched columns. for col in column_names: if any(_keyword_in_column(keyword, col) for keyword in image_keywords): sample_value = sample[col] score = _score_image_candidate(col, sample_value) if score > 0: candidates.append((col, score)) # Pass 2: value-based fallback for image URLs/paths even when the name # doesn't match keywords. already = {c[0] for c in candidates} for col in column_names: if col in already: continue sample_value = sample[col] if _is_image_value(sample_value): score = _score_image_candidate(col, sample_value) # Penalise non-keyword columns so keyword matches win on ties. candidates.append((col, max(score - 5, 1))) if not candidates: return None candidates.sort(key = lambda x: x[1], reverse = True) # Single candidate or top is PIL/dict — no probing needed. if len(candidates) == 1 or candidates[0][1] >= 75: return candidates[0][0] # Multiple string candidates — probe for one that works. for col, score in candidates: sample_value = sample[col] if _probe_image_candidate(col, sample_value): return col # None probed OK — return highest-scored; conversion may still resolve it. return candidates[0][0] def find_text_column(): """Find text column: skip metadata, match keywords.""" candidates = [] for col in column_names: if is_metadata_column(col): continue if any(_keyword_in_column(keyword, col) for keyword in text_keywords): sample_value = sample[col] if isinstance(sample_value, str) and len(sample_value) > 0: # Longer text = higher priority (content, not a label). priority = min(len(sample_value), 1000) candidates.append((col, priority)) elif ( isinstance(sample_value, list) and len(sample_value) > 0 and isinstance(sample_value[0], str) ): # List of strings (e.g. captions) — lower priority than plain str. priority = min(len(sample_value[0]), 1000) // 2 candidates.append((col, priority)) if candidates: candidates.sort(key = lambda x: x[1], reverse = True) return candidates[0][0] return None found_image = find_image_column() found_text = find_text_column() if found_image and found_text: return { "format": "simple_image_text", "needs_conversion": True, "image_column": found_image, "text_column": found_text, "messages_column": None, } return { "format": "unknown", "needs_conversion": None, "image_column": found_image, "text_column": found_text, "messages_column": None, }