Source code for phenotypic.post._expand_metadata

from __future__ import annotations

import re
from typing import List

import pandas as pd

from phenotypic.abc_._post_measurement import PostMeasurement
from ._utils import _ensure_prefix


[docs] class ExpandMetadata(PostMeasurement): """Split a metadata column into multiple new metadata columns. Takes a single delimited metadata column (e.g., a filename encoding experimental conditions) and expands it into separate labeled columns. Every row must produce exactly len(labels) parts or a ValueError is raised. Args: column: Name of the metadata column to split. ``Metadata_`` prefix is added automatically if missing. labels: Names for the resulting columns, one per split part. ``Metadata_`` prefix is added automatically if missing. delimiter: String or regex pattern to split on. Defaults to ``"_"``. regex: If True, treat delimiter as a regex pattern. Defaults to False. Returns: pd.DataFrame: The input DataFrame with new columns inserted adjacent to the source column. The source column is always kept. Raises: ValueError: If labels is empty, or if any row produces a different number of parts than len(labels). KeyError: If the source column does not exist in the DataFrame. Examples: Split a filename into experimental conditions: >>> import pandas as pd >>> from phenotypic.post import ExpandMetadata >>> df = pd.DataFrame({ ... "Metadata_ImageName": ["WT_30C_24h", "mut_37C_48h"], ... "ObjectLabel": [1, 2], ... }) >>> expand = ExpandMetadata( ... column="ImageName", ... labels=["Strain", "Condition", "Time"], ... delimiter="_", ... ) >>> result = expand.apply(df) >>> list(result["Metadata_Strain"]) ['WT', 'mut'] """ def __init__( self, column: str = "", labels: List[str] | None = None, delimiter: str = "_", regex: bool = False, ): super().__init__() if labels is not None and not labels: raise ValueError("labels must be a non-empty list") self.column = _ensure_prefix(column) if column else "" self.labels = [_ensure_prefix(lbl) for lbl in labels] if labels else [] self.delimiter = delimiter self.regex = regex def _operate(self, df: pd.DataFrame) -> pd.DataFrame: """Split the metadata column and insert new columns. Args: df: Measurement DataFrame containing the source column. Returns: DataFrame with new columns inserted after the source column. """ if self.column not in df.columns: raise KeyError( f"Column '{self.column}' not found in DataFrame. " f"Available columns: {list(df.columns)}" ) # Split the column values if self.regex: parts = df[self.column].apply(lambda x: re.split(self.delimiter, str(x))) else: parts = df[self.column].str.split(self.delimiter) # Validate that every row has the expected number of parts n_expected = len(self.labels) counts = parts.apply(len) bad_mask = counts != n_expected if bad_mask.any(): first_bad_idx = bad_mask.idxmax() first_bad_val = df[self.column].iloc[first_bad_idx] first_bad_count = counts.iloc[first_bad_idx] raise ValueError( f"Column '{self.column}' split produced {first_bad_count} parts " f"for value '{first_bad_val}', but {n_expected} labels were " f"provided: {self.labels}" ) # Build new columns DataFrame split_df = pd.DataFrame(parts.tolist(), columns=self.labels, index=df.index) # Insert new columns after the source column src_pos = df.columns.get_loc(self.column) result = df.copy() for i, label in enumerate(self.labels): result.insert(src_pos + 1 + i, label, split_df[label]) return result