# 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 conversion between Alpaca, ShareGPT, and ChatML.""" import os from .iterable import is_streaming_dataset from loggers import get_logger logger = get_logger(__name__) def standardize_chat_format( dataset, tokenizer = None, aliases_for_system = [ "system", ], aliases_for_user = [ "user", "human", "input", ], aliases_for_assistant = [ "gpt", "assistant", "output", ], batch_size = 1000, num_proc = None, chat_column: str | None = None, ): """ Standardize BOTH messages and conversations: map non-standard role names and keys to the standard format. """ import collections import itertools # Check if vision tokenizer is used is_vlm = False if tokenizer is not None: if hasattr(tokenizer, "image_processor") or hasattr(tokenizer, "tokenizer"): is_vlm = True column_names = set(next(iter(dataset)).keys()) if chat_column: if chat_column not in column_names: return dataset elif "conversations" in column_names: chat_column = "conversations" elif "messages" in column_names: chat_column = "messages" elif "texts" in column_names: chat_column = "texts" else: return dataset # No chat column found def _iter_probe_rows(): try: total = min(len(dataset), 100) for index in range(total): yield dataset[index] return except Exception: pass for example in itertools.islice(dataset, 100): yield example uniques = collections.defaultdict(list) for example in _iter_probe_rows(): chat_data = example.get(chat_column) if not isinstance(chat_data, list) or len(chat_data) == 0: continue for message in chat_data: if not isinstance(message, dict): continue for key, value in message.items(): if type(value) is not str: continue # Skip non-strings uniques[key].append(value) if "from" in uniques and "value" in uniques: role_key = "from" content_key = "value" elif "role" in uniques and "content" in uniques: role_key = "role" content_key = "content" elif len(uniques.keys()) == 2: keys = list(uniques.keys()) length_first = len(set(uniques[keys[0]])) length_second = len(set(uniques[keys[1]])) if length_first < length_second: role_key = keys[0] content_key = keys[1] else: role_key = keys[1] content_key = keys[0] else: raise ValueError(f"Could not infer role/content keys for chat column '{chat_column}'") # Mapping for aliases aliases_mapping = {} for x in aliases_for_system: aliases_mapping[x] = "system" for x in aliases_for_user: aliases_mapping[x] = "user" for x in aliases_for_assistant: aliases_mapping[x] = "assistant" def _standardize_dataset(examples): convos = examples[chat_column] all_convos = [] for convo in convos: if not isinstance(convo, list): all_convos.append([]) continue new_convo = [] for message in convo: if not isinstance(message, dict): continue # Use the inferred keys first; fall back per-message so mixed # ShareGPT/ChatML rows keep valid turns. original_role = message.get(role_key) original_content = message.get(content_key) if original_role is None: original_role = message.get("role") or message.get("from") or "" if original_content is None: original_content = message.get("content") or message.get("value") or "" standard_role = aliases_mapping.get(original_role, original_role) if is_vlm: original_content = [{"type": "text", "text": original_content}] # Keep EXPLICIT key order new_message = {"role": standard_role, "content": original_content} new_convo.append(new_message) all_convos.append(new_convo) return {chat_column: all_convos} dataset_map_kwargs = { "batched": True, "batch_size": batch_size, } if not is_streaming_dataset(dataset): from utils.hardware import dataset_map_num_proc if num_proc is None or type(num_proc) is not int: num_proc = dataset_map_num_proc() else: num_proc = dataset_map_num_proc(num_proc) dataset_map_kwargs["num_proc"] = num_proc dataset_map_kwargs["desc"] = "Standardizing chat format" result = dataset.map(_standardize_dataset, **dataset_map_kwargs) # For streaming, force the first mapped row through now so any # column/format errors surface before training begins (not mid-iteration). # IterableDataset re-iterates from the generator source, so this is safe. if is_streaming_dataset(dataset): try: next(iter(result)) except Exception as exc: raise ValueError( f"Streaming chat-format standardization failed on the first row: {exc}" ) from exc return result def convert_chatml_to_alpaca( dataset, batch_size = 1000, num_proc = None, chat_column: str | None = None, ): """ Convert ChatML (messages OR conversations) to Alpaca format. Supports: - "messages" or "conversations" column - "role"/"content" (standard) or "from"/"value" (ShareGPT) """ is_iterable = is_streaming_dataset(dataset) def _convert(examples): chatml_data = examples.get(chat_column) if chat_column else None if chatml_data is None: chatml_data = ( examples.get("messages") or examples.get("conversations") or examples.get("texts") ) if chatml_data is None: raise ValueError("No 'messages' or 'conversations' or 'texts' column found.") instructions = [] outputs = [] inputs = [] for convo in chatml_data: instruction = "" output = "" for msg in convo: # Standard and ShareGPT key names role = msg.get("role") or msg.get("from") content = msg.get("content") or msg.get("value") # First user message -> instruction if role in ["user", "human", "input"] and not instruction: instruction = content # First assistant message -> output elif role in ["assistant", "gpt", "output"] and not output: output = content break # Stop after first assistant response instructions.append(instruction) inputs.append("") # Alpaca input usually empty outputs.append(output) return {"instruction": instructions, "input": inputs, "output": outputs} dataset_map_kwargs = { "batched": True, "batch_size": batch_size, } if not is_iterable: from utils.hardware import dataset_map_num_proc if num_proc is None or type(num_proc) is not int: num_proc = dataset_map_num_proc() else: num_proc = dataset_map_num_proc(num_proc) dataset_map_kwargs["num_proc"] = num_proc dataset_map_kwargs["desc"] = "Converting ChatML to Alpaca format" result = dataset.map(_convert, **dataset_map_kwargs) # For streaming, force the first mapped row through now so any # column/format errors surface before training begins (not mid-iteration). # IterableDataset re-iterates from the generator source, so this is safe. if is_iterable: try: next(iter(result)) except Exception as exc: raise ValueError( f"Streaming ChatML-to-Alpaca conversion failed on the first row: {exc}" ) from exc return result def convert_alpaca_to_chatml( dataset, batch_size = 1000, num_proc = None, ): """ Convert Alpaca format to ChatML format. Output: 'conversations' column with standard 'role'/'content' dicts. """ is_iterable = is_streaming_dataset(dataset) def _convert(examples): conversations = [] for i in range(len(examples["instruction"])): instruction = examples["instruction"][i] input_text = examples.get("input", [""] * len(examples["instruction"]))[i] output = examples["output"][i] # User message = instruction + input (if any) if input_text and input_text.strip(): user_content = f"{instruction}\n\n{input_text}".strip() else: user_content = instruction convo = [ {"role": "user", "content": user_content}, {"role": "assistant", "content": output}, ] conversations.append(convo) return {"conversations": conversations} dataset_map_kwargs = { "batched": True, "batch_size": batch_size, } if not is_iterable: from utils.hardware import dataset_map_num_proc if num_proc is None or type(num_proc) is not int: num_proc = dataset_map_num_proc() else: num_proc = dataset_map_num_proc(num_proc) dataset_map_kwargs["num_proc"] = num_proc dataset_map_kwargs["desc"] = "Converting Alpaca to ChatML format" result = dataset.map(_convert, **dataset_map_kwargs) # For streaming, force the first mapped row through now so any # column/format errors surface before training begins (not mid-iteration). # IterableDataset re-iterates from the generator source, so this is safe. if is_iterable: try: next(iter(result)) except Exception as exc: raise ValueError( f"Streaming Alpaca-to-ChatML conversion failed on the first row: {exc}" ) from exc return result def _format_eta(seconds): """Format seconds into a human-readable ETA string.""" if seconds < 60: return f"{seconds:.0f}s" elif seconds < 3600: m, s = divmod(int(seconds), 60) return f"{m}m {s}s" else: h, remainder = divmod(int(seconds), 3600) m, _ = divmod(remainder, 60) return f"{h}h {m}m" def convert_to_vlm_format( dataset, instruction = None, text_column = "text", image_column = "image", dataset_name = None, progress_callback = None, ): """ Convert simple {image, text} format to VLM messages format. Returns a LIST, not a HuggingFace Dataset (to preserve PIL Images). For URL-based datasets, runs a 200-sample parallel probe first to estimate speed/failure rate via progress_callback. Args: progress_callback: Optional callable(status_message=str) for progress. Returns: list: List of dicts with 'messages' field """ from PIL import Image from .vlm_processing import generate_smart_vlm_instruction def _notify(msg): """Send a status update to the training overlay if callback set.""" if progress_callback: progress_callback(status_message = msg) # Generate a smart instruction if none provided if instruction is None: instruction_info = generate_smart_vlm_instruction( dataset, text_column = text_column, image_column = image_column, dataset_name = dataset_name, ) instruction = instruction_info["instruction"] instruction_column = instruction_info.get("instruction_column") uses_dynamic = instruction_info["uses_dynamic_instruction"] logger.info(f"πŸ“ Auto-detected instruction type: {instruction_info['instruction_type']}") logger.info(f"πŸ“ Confidence: {instruction_info['confidence']:.2f}") if not uses_dynamic: logger.info(f"πŸ“ Using instruction: '{instruction}'") else: logger.info(f"πŸ“ Using dynamic instructions from column: '{instruction_column}'") else: instruction_column = None uses_dynamic = False def _convert_single_sample(sample): """Convert a single sample to VLM format.""" # Image may be a PIL Image, local path, URL, or bare filename image_data = sample[image_column] if isinstance(image_data, str): if image_data.startswith(("http://", "https://")): import fsspec from io import BytesIO with fsspec.open(image_data, "rb", expand = True) as f: image_data = Image.open(BytesIO(f.read())).convert("RGB") elif _image_lookup is not None and image_data in _image_lookup: # Bare filename β†’ resolve via HF repo lookup from huggingface_hub import hf_hub_download local_path = hf_hub_download( dataset_name, _image_lookup[image_data], repo_type = "dataset", ) image_data = Image.open(local_path).convert("RGB") else: image_data = Image.open(image_data).convert("RGB") # Text: if a list (e.g. multiple captions), pick one at random text_data = sample[text_column] if isinstance(text_data, list) and len(text_data) > 0: import random text_data = random.choice(text_data) # Instruction: static or dynamic if uses_dynamic and instruction_column: current_instruction = sample[instruction_column] else: current_instruction = instruction messages = [ { "role": "user", "content": [ {"type": "text", "text": current_instruction}, {"type": "image", "image": image_data}, # PIL object ], }, {"role": "assistant", "content": [{"type": "text", "text": text_data}]}, ] return {"messages": messages} total = len(dataset) first_image = next(iter(dataset))[image_column] has_urls = isinstance(first_image, str) and first_image.startswith(("http://", "https://")) # ── Bare-filename detection: build a basenameβ†’repo_path lookup so # filename-only images resolve via hf_hub_download during conversion. _image_lookup = None _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff") if ( not has_urls and isinstance(first_image, str) and not os.path.exists(first_image) and dataset_name ): try: from huggingface_hub import HfApi _notify("Resolving image filenames from HF repo...") logger.info( f"πŸ” Image column contains bare filenames (e.g. '{first_image}') β€” building repo lookup..." ) repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset") _image_lookup = { os.path.basename(f): f for f in repo_files if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS) } if first_image in _image_lookup: logger.info( f"βœ… Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' β†’ '{_image_lookup[first_image]}')" ) else: logger.info( f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found β€” falling back to local open" ) _image_lookup = None except Exception as e: logger.info(f"⚠️ Failed to build HF repo image lookup: {e}") _image_lookup = None # ── URL probe: 200 parallel samples to estimate speed + failure rate ── PROBE_SIZE = 200 MAX_FAIL_RATE = 0.3 if has_urls and total > PROBE_SIZE: import time from concurrent.futures import ThreadPoolExecutor, as_completed from utils.hardware import safe_thread_num_proc num_workers = safe_thread_num_proc() _notify(f"Probing {PROBE_SIZE} image URLs with {num_workers} workers...") logger.info(f"πŸ” Probing {PROBE_SIZE}/{total} image URLs with {num_workers} workers...") probe_samples = [dataset[i] for i in range(PROBE_SIZE)] probe_ok = 0 probe_fail = 0 probe_start = time.time() with ThreadPoolExecutor(max_workers = num_workers) as executor: futures = {executor.submit(_convert_single_sample, s): s for s in probe_samples} for future in as_completed(futures): try: future.result() probe_ok += 1 except Exception: probe_fail += 1 probe_elapsed = time.time() - probe_start probe_total = probe_ok + probe_fail fail_rate = probe_fail / probe_total if probe_total > 0 else 0 throughput = probe_total / probe_elapsed if probe_elapsed > 0 else 0 if fail_rate >= MAX_FAIL_RATE: issues = [ f"{fail_rate:.0%} of the first {PROBE_SIZE} image URLs failed to download ({probe_fail}/{probe_total})", "Images are external URLs, not embedded in the dataset", ] # LLM-friendly warning friendly = None try: from .llm_assist import llm_generate_dataset_warning friendly = llm_generate_dataset_warning( issues, dataset_name = dataset_name, modality = "vision", column_names = [image_column, text_column], ) except Exception: pass msg = friendly or ( f"⚠️ {fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " f"({probe_fail}/{probe_total}). " "This dataset has too many broken or unreachable image URLs. " "Consider using a dataset with embedded images instead." ) logger.info(msg) _notify(msg) raise ValueError(msg) # Estimate time for remaining samples remaining = total - PROBE_SIZE estimated_seconds = remaining / throughput if throughput > 0 else 0 eta_str = _format_eta(estimated_seconds) info_msg = ( f"Downloading {total:,} images ({num_workers} workers, ~{throughput:.1f} img/s). " f"Estimated time: ~{eta_str}" ) if probe_fail > 0: info_msg += f" | {fail_rate:.0%} broken URLs will be skipped" logger.info( f"βœ… Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s" ) logger.info(f"⏱️ Estimated time for {total:,} samples: ~{eta_str}") _notify(info_msg) # ── Full conversion with progress ── from tqdm import tqdm logger.info(f"πŸ”„ Converting {total} samples to VLM format...") converted_list = [] failed_count = 0 if has_urls: # Parallel conversion for URL-based datasets import time from concurrent.futures import ThreadPoolExecutor, as_completed from utils.hardware import safe_thread_num_proc num_workers = safe_thread_num_proc() batch_size = 500 start_time = time.time() for batch_start in range(0, total, batch_size): batch_end = min(batch_start + batch_size, total) batch_samples = [dataset[i] for i in range(batch_start, batch_end)] with ThreadPoolExecutor(max_workers = num_workers) as executor: futures = { executor.submit(_convert_single_sample, s): i for i, s in enumerate(batch_samples) } batch_results = [None] * len(batch_samples) for future in as_completed(futures): idx = futures[future] try: batch_results[idx] = future.result() except Exception as e: failed_count += 1 if failed_count == 1: logger.info(f"First VLM conversion failure: {type(e).__name__}: {e}") converted_list.extend(r for r in batch_results if r is not None) # Per-batch progress update elapsed = time.time() - start_time done = batch_end rate = done / elapsed if elapsed > 0 else 0 remaining_time = (total - done) / rate if rate > 0 else 0 eta_str = _format_eta(remaining_time) progress_msg = f"Downloading images: {done:,}/{total:,} ({done*100//total}%) | ~{eta_str} remaining | {failed_count} skipped" logger.info( f" [{done}/{total}] {rate:.1f} img/s, {failed_count} failed, ETA {eta_str}" ) _notify(progress_msg) else: # Sequential conversion for local/embedded images (no I/O bottleneck) pbar = tqdm(dataset, total = total, desc = "Converting VLM samples", unit = "sample") for sample in pbar: try: converted_list.append(_convert_single_sample(sample)) except Exception as e: failed_count += 1 if failed_count == 1: # Log the first failure to aid debugging logger.info(f"First VLM conversion failure: {type(e).__name__}: {e}") pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False) pbar.close() if failed_count > 0: fail_rate = failed_count / total logger.info( f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images" ) # Small URL datasets skip the probe; check fail rate here if has_urls and fail_rate >= MAX_FAIL_RATE: issues = [ f"{fail_rate:.0%} of images failed to download ({failed_count}/{total})", "Images are external URLs, not embedded in the dataset", ] friendly = None try: from .llm_assist import llm_generate_dataset_warning friendly = llm_generate_dataset_warning( issues, dataset_name = dataset_name, modality = "vision", column_names = [image_column, text_column], ) except Exception: pass msg = friendly or ( f"⚠️ {fail_rate:.0%} of images failed to download ({failed_count}/{total}). " "This dataset has too many broken or unreachable image URLs. " "Consider using a dataset with embedded images instead." ) _notify(msg) raise ValueError(msg) if len(converted_list) == 0: issues = [ f"All {total} samples failed during VLM conversion β€” no usable images found", f"Image column '{image_column}' may contain URLs that are no longer accessible, " "or local file paths that don't exist", ] friendly = None try: from .llm_assist import llm_generate_dataset_warning friendly = llm_generate_dataset_warning( issues, dataset_name = dataset_name, modality = "vision", column_names = [image_column, text_column], ) except Exception: pass raise ValueError( friendly or ( f"All {total} samples failed during VLM conversion β€” no usable images found. " "This dataset may contain only image URLs that are no longer accessible." ) ) logger.info(f"βœ… Converted {len(converted_list)}/{total} samples") _notify(f"Converted {len(converted_list):,}/{total:,} images successfully") # Return list, NOT a Dataset return converted_list def convert_sharegpt_with_images_to_vlm_format( dataset, image_column = "image", messages_column = "conversations", dataset_name = None, progress_callback = None, ): """ Convert ShareGPT/ChatML datasets with a separate image column and ```` placeholders in the conversation text. Example input:: { "image": "sam/images/sa_545504.jpg", "conversations": [ {"from": "human", "value": "\\nWhat is this photo about?"}, {"from": "gpt", "value": "The image captures..."} ] } Returns a list of dicts in standard VLM messages format (PIL Images inline). """ from PIL import Image from tqdm import tqdm _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff") _ROLE_MAP = { "human": "user", "user": "user", "gpt": "assistant", "assistant": "assistant", "system": "system", } def _notify(msg): if progress_callback: progress_callback(status_message = msg) # ── Resolve image loading (same 3-tier as convert_to_vlm_format) ── total = len(dataset) first_image = next(iter(dataset))[image_column] _image_lookup = None if ( isinstance(first_image, str) and not first_image.startswith(("http://", "https://")) and not os.path.exists(first_image) and dataset_name ): try: from huggingface_hub import HfApi _notify("Resolving image filenames from HF repo...") logger.info( f"πŸ” Image column contains bare filenames (e.g. '{first_image}') β€” building repo lookup..." ) repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset") _image_lookup = { os.path.basename(f): f for f in repo_files if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS) } # Also key by full relative path (e.g. "sam/images/sa_545504.jpg") for f in repo_files: if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS): _image_lookup[f] = f if first_image in _image_lookup: logger.info( f"βœ… Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' β†’ '{_image_lookup[first_image]}')" ) else: logger.info( f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found β€” falling back to local open" ) _image_lookup = None except Exception as e: logger.info(f"⚠️ Failed to build HF repo image lookup: {e}") _image_lookup = None def _resolve_image(image_data): """Resolve image data to a PIL Image.""" if hasattr(image_data, "size") and hasattr(image_data, "mode"): return image_data # Already PIL if isinstance(image_data, str): if image_data.startswith(("http://", "https://")): import fsspec from io import BytesIO with fsspec.open(image_data, "rb", expand = True) as f: return Image.open(BytesIO(f.read())).convert("RGB") elif _image_lookup is not None and image_data in _image_lookup: from huggingface_hub import hf_hub_download local_path = hf_hub_download( dataset_name, _image_lookup[image_data], repo_type = "dataset", ) return Image.open(local_path).convert("RGB") else: return Image.open(image_data).convert("RGB") if isinstance(image_data, dict) and ("bytes" in image_data or "path" in image_data): if image_data.get("bytes"): from io import BytesIO return Image.open(BytesIO(image_data["bytes"])).convert("RGB") if image_data.get("path"): return Image.open(image_data["path"]).convert("RGB") raise ValueError(f"Cannot resolve image: {type(image_data)}") def _convert_single_sample(sample): """Convert one ShareGPT+image sample to standard VLM format.""" pil_image = _resolve_image(sample[image_column]) conversation = sample[messages_column] new_messages = [] for msg in conversation: role_raw = msg.get("from") or msg.get("role", "user") role = _ROLE_MAP.get(role_raw.lower(), role_raw.lower()) text = msg.get("value") or msg.get("content") or "" # Interleave text and image blocks around if "" in text: parts = text.split("") content = [] for i, part in enumerate(parts): part = part.strip() if part: content.append({"type": "text", "text": part}) if i < len(parts) - 1: content.append({"type": "image", "image": pil_image}) # If text was only , content is just the image if not content: content.append({"type": "image", "image": pil_image}) else: content = [{"type": "text", "text": text}] new_messages.append({"role": role, "content": content}) return {"messages": new_messages} # ── Full conversion with progress ── logger.info(f"πŸ”„ Converting {total} samples from ShareGPT+image format...") converted_list = [] failed_count = 0 pbar = tqdm(dataset, total = total, desc = "Converting ShareGPT+image", unit = "sample") for sample in pbar: try: converted_list.append(_convert_single_sample(sample)) except Exception as e: failed_count += 1 if failed_count == 1: logger.info(f"⚠️ First conversion failure: {type(e).__name__}: {e}") pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False) pbar.close() if failed_count > 0: logger.info(f"⚠️ Skipped {failed_count}/{total} ({failed_count*100//total}%) samples") if len(converted_list) == 0: raise ValueError( f"All {total} samples failed during ShareGPT+image conversion β€” " "no usable samples found." ) logger.info(f"βœ… Converted {len(converted_list)}/{total} samples") _notify(f"Converted {len(converted_list):,}/{total:,} samples successfully") return converted_list def convert_llava_to_vlm_format(dataset): """ Convert Llava format to standard VLM format. Llava format: - messages: [{'content': [{'type': 'image', 'index': 0}, {'type': 'text', 'text': '...'}]}] - images: [PIL_Image1, PIL_Image2, ...] Standard VLM format: - messages: [{'content': [{'type': 'image', 'image': PIL_Image}, {'type': 'text', 'text': '...'}]}] """ from PIL import Image logger.info(f"πŸ”„ Converting {len(dataset)} samples from Llava format to standard VLM format...") def _convert_single_sample(sample): """Convert one llava sample to standard VLM format.""" messages = sample["messages"] images = sample.get("images", []) new_messages = [] for msg in messages: new_content = [] for item in msg["content"]: if item["type"] == "image": # Replace index with the actual PIL image if "index" in item and item["index"] is not None: img_idx = item["index"] if img_idx < len(images): pil_image = images[img_idx] # Ensure PIL if isinstance(pil_image, str): pil_image = Image.open(pil_image).convert("RGB") new_content.append( { "type": "image", "image": pil_image, # Actual PIL object } ) else: # No index: use the first image if len(images) > 0: pil_image = images[0] if isinstance(pil_image, str): pil_image = Image.open(pil_image).convert("RGB") new_content.append({"type": "image", "image": pil_image}) elif item["type"] == "text": new_content.append({"type": "text", "text": item.get("text", "")}) new_messages.append({"role": msg["role"], "content": new_content}) return {"messages": new_messages} converted_list = [_convert_single_sample(sample) for sample in dataset] logger.info(f"βœ… Converted {len(converted_list)} samples") return converted_list