Source code for melody_features.reshape
"""Reshape helpers for the wide-format DataFrame returned by `get_all_features`."""
from __future__ import annotations
from typing import Optional
import pandas as pd
from .feature_metadata import get_feature_metadata
ID_VARS = ["melody_num", "melody_id"]
_METADATA_VALUE_COLUMNS = ["family", "source", "domain", "type", "description", "notes", "references"]
def _fallback_family(feature_name: str) -> str:
"""Infer a family from a `feature_name` that has no exact metadata match.
This chiefly covers dynamically-named IDyOM columns (`idyom.<config>_<metric>`)
and the handful of legacy columns that `get_all_features` computes twice
under two family prefixes for backward compatibility (see
`feature_metadata.get_feature_metadata`'s docstring).
"""
return feature_name.split(".", 1)[0] if "." in feature_name else feature_name
[docs]
def to_long_format(
df: pd.DataFrame,
*,
join_metadata: bool = True,
metadata: Optional[pd.DataFrame] = None,
) -> pd.DataFrame:
"""Reshape a wide-format feature DataFrame into tidy long format.
Parameters
----------
df : pd.DataFrame
A wide-format DataFrame as returned by `get_all_features`, with
`melody_num`/`melody_id` identifier columns and one `{family}.{feature}`
column per feature.
join_metadata : bool, optional
If True (default), left-join feature metadata (family, source,
domain, type, description, notes, references) onto the long
DataFrame by `feature_name`. Any `feature_name` without an exact
metadata match (chiefly dynamic IDyOM columns) falls back to a
family/source inferred from the column prefix rather than being
left blank.
metadata : pd.DataFrame, optional
A metadata table to join instead of the default
:func:`melody_features.get_feature_metadata` table (for example, a
filtered or user-extended version). Must contain a `feature_name`
column plus any of the columns in
`family, source, domain, type, description, notes, references`.
Returns
-------
pd.DataFrame
Columns: `melody_num`, `melody_id`, `feature_name`, `value`, and
(when `join_metadata=True`) `family`, `source`, `domain`, `type`,
`description`, `notes`, `references`.
"""
id_vars = [col for col in ID_VARS if col in df.columns]
value_vars = [col for col in df.columns if col not in id_vars]
long_df = pd.melt(
df,
id_vars=id_vars,
value_vars=value_vars,
var_name="feature_name",
value_name="value",
)
# Keep heterogeneous feature values (scalars, lists, dicts) intact rather
# than letting pandas upcast everything to a common dtype.
long_df["value"] = long_df["value"].astype(object)
if not join_metadata:
return long_df
meta = metadata if metadata is not None else get_feature_metadata()
meta_cols = ["feature_name"] + [c for c in _METADATA_VALUE_COLUMNS if c in meta.columns]
long_df = long_df.merge(meta[meta_cols], on="feature_name", how="left")
unmatched = long_df["family"].isna()
if unmatched.any():
long_df.loc[unmatched, "family"] = long_df.loc[unmatched, "feature_name"].map(_fallback_family)
if "source" in long_df.columns:
is_idyom = unmatched & (long_df["family"] == "idyom")
long_df.loc[is_idyom, "source"] = "IDyOM"
trailing_meta_cols = [c for c in ("description", "notes", "references") if c in long_df.columns]
final_cols = id_vars + ["feature_name", "family", "source", "domain", "type", "value"] + trailing_meta_cols
return long_df[[c for c in final_cols if c in long_df.columns]]