Skip to content

API: preprocessing.casting

skyulf.preprocessing.casting

Type-casting node — routes apply through the dual-engine dispatcher.

The fit step resolves configuration and freezes categorical vocabularies. The pandas apply path was previously a single CCN-busting function; it is now split per dtype family (float / int / bool / datetime / other).

CastingApplier

Bases: BaseApplier

Cast each column named in type_map to its target dtype on the active engine.

coerce_on_error=True (the default) is best-effort per column: the pandas path swallows any cast failure and leaves that column untouched, the polars path casts with strict=False. With it off, both engines raise. The awkward families are hand-written on each path because the native casts disagree — polars truncates fractional floats where pandas masks them, and pandas' astype clamps out-of-range integers where polars nulls or raises. Boolean targets additionally go through the shared alias table so "yes"/"no"-style strings coerce the same way on both engines in best-effort mode, including Polars Categorical and Enum labels. Text-to-datetime casts share the pandas mixed-format parser and retain the resolved engine dtype. Columns missing from the frame are skipped. New categorical artifacts freeze the training vocabulary; unseen values become missing. Older artifacts without a vocabulary retain their original casting behavior.

Source code in skyulf-core/skyulf/preprocessing/casting.py
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
class CastingApplier(BaseApplier):
    """Cast each column named in ``type_map`` to its target dtype on the active engine.

    ``coerce_on_error=True`` (the default) is best-effort per column: the pandas
    path swallows any cast failure and leaves that column untouched, the polars
    path casts with ``strict=False``. With it off, both engines raise. The
    awkward families are hand-written on each path because the native casts
    disagree — polars truncates fractional floats where pandas masks them, and
    pandas' ``astype`` clamps out-of-range integers where polars nulls or raises.
    Boolean targets additionally go through the shared alias table so
    "yes"/"no"-style strings coerce the same way on both engines in best-effort
    mode, including Polars Categorical and Enum labels. Text-to-datetime casts
    share the pandas mixed-format parser and retain the resolved engine dtype.
    Columns missing from the frame are skipped. New categorical artifacts
    freeze the training vocabulary; unseen values become missing. Older
    artifacts without a vocabulary retain their original casting behavior.
    """

    @staticmethod
    def validate_inference_state(raw: dict) -> dict:
        """Inspect local dtype/vocabulary state without converting or relearning it."""
        fields = {"type", "type_map", "coerce_on_error"}
        # Optional fields: older artifacts predate them and keep their behavior.
        fields |= {"categories", "integral_floats_as_int"} & set(
            raw if isinstance(raw, dict) else ()
        )
        local_state_fields(raw, "casting", fields)
        _validate_casting_types(raw["type_map"])
        local_boolean(raw["coerce_on_error"], "coerce_on_error")
        if "integral_floats_as_int" in raw:
            local_boolean(raw["integral_floats_as_int"], "integral_floats_as_int")
        _validate_casting_categories(raw)
        return raw

    @staticmethod
    def inference_capability(state: dict, *, engine: str) -> ExecutionCapability | None:
        """Describe local casts, including request-derived legacy pandas vocabularies."""
        if engine not in ("pandas", "polars"):
            return None
        CastingApplier.validate_inference_state(state)
        context = _pandas_cast_context(state) if engine == "pandas" else "row"
        return ExecutionCapability(engine, "apply", "local", "preserve", context)

    @apply_method
    def apply(self, X: Any, _y: Any, params: dict[str, Any]) -> Any:  # pylint: disable=arguments-differ
        """Dispatch through the mapping-keyed dual-engine path; ``y`` passes through."""
        return apply_dual_engine(
            X, params, {"polars": _casting_apply_polars, "pandas": _casting_apply_pandas}
        )

apply(X, _y, params)

Dispatch through the mapping-keyed dual-engine path; y passes through.

Source code in skyulf-core/skyulf/preprocessing/casting.py
633
634
635
636
637
638
@apply_method
def apply(self, X: Any, _y: Any, params: dict[str, Any]) -> Any:  # pylint: disable=arguments-differ
    """Dispatch through the mapping-keyed dual-engine path; ``y`` passes through."""
    return apply_dual_engine(
        X, params, {"polars": _casting_apply_polars, "pandas": _casting_apply_pandas}
    )

inference_capability(state, *, engine) staticmethod

Describe local casts, including request-derived legacy pandas vocabularies.

Source code in skyulf-core/skyulf/preprocessing/casting.py
624
625
626
627
628
629
630
631
@staticmethod
def inference_capability(state: dict, *, engine: str) -> ExecutionCapability | None:
    """Describe local casts, including request-derived legacy pandas vocabularies."""
    if engine not in ("pandas", "polars"):
        return None
    CastingApplier.validate_inference_state(state)
    context = _pandas_cast_context(state) if engine == "pandas" else "row"
    return ExecutionCapability(engine, "apply", "local", "preserve", context)

validate_inference_state(raw) staticmethod

Inspect local dtype/vocabulary state without converting or relearning it.

Source code in skyulf-core/skyulf/preprocessing/casting.py
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
@staticmethod
def validate_inference_state(raw: dict) -> dict:
    """Inspect local dtype/vocabulary state without converting or relearning it."""
    fields = {"type", "type_map", "coerce_on_error"}
    # Optional fields: older artifacts predate them and keep their behavior.
    fields |= {"categories", "integral_floats_as_int"} & set(
        raw if isinstance(raw, dict) else ()
    )
    local_state_fields(raw, "casting", fields)
    _validate_casting_types(raw["type_map"])
    local_boolean(raw["coerce_on_error"], "coerce_on_error")
    if "integral_floats_as_int" in raw:
        local_boolean(raw["integral_floats_as_int"], "integral_floats_as_int")
    _validate_casting_categories(raw)
    return raw

CastingCalculator

Bases: BaseCalculator

Normalise either accepted config shape into one canonical type_map.

Ordinary casts resolve dtype aliases through TYPE_ALIASES. Category casts additionally learn the observed training vocabulary so applying a held-out batch cannot change the accepted categories or pandas codes.

Source code in skyulf-core/skyulf/preprocessing/casting.py
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
@NodeRegistry.register("Casting", CastingApplier)
@node_meta(
    id="Casting",
    name="Type Casting",
    category="Data Operations",
    description="Cast columns to specific data types.",
    params={"column_types": {}, "coerce_on_error": True},
    learns_from_data=True,
)
class CastingCalculator(BaseCalculator):
    """Normalise either accepted config shape into one canonical ``type_map``.

    Ordinary casts resolve dtype aliases through ``TYPE_ALIASES``. Category
    casts additionally learn the observed training vocabulary so applying a
    held-out batch cannot change the accepted categories or pandas codes.
    """

    def infer_output_schema(
        self, input_schema: SkyulfSchema, config: dict[str, Any]
    ) -> SkyulfSchema:
        """Rewrite the dtype labels of ``input_schema`` for every column the config targets.

        The shared ``target_type`` overrides per-column choices, matching fit.
        Columns absent from the input schema are skipped, as they are at runtime.
        """
        # Casting preserves the column set but rewrites dtype labels.
        column_types = dict(config.get("column_types", {}) or {})
        target_type = config.get("target_type")
        columns = config.get("columns", []) or []
        if target_type and columns:
            for col in columns:
                column_types[col] = target_type
        new_schema = input_schema
        for col, dtype in column_types.items():
            resolved = _resolve_casting_dtype(dtype)
            new_schema = new_schema.with_dtype(col, str(resolved))
        return new_schema

    @fit_method
    def fit(self, X: Any, _y: Any, config: dict[str, Any]) -> CastingArtifact:  # pylint: disable=arguments-differ
        """Merge the two config shapes into a ``type_map`` of columns present on *X*.

        Absent columns are dropped rather than erroring, so a config written
        against a wider frame degrades to a partial cast; when both shapes name
        the same column, ``target_type`` overwrites the ``column_types`` entry.
        """
        # Config supports two shapes:
        #   {'columns': ['col1'], 'target_type': 'float'}
        #   {'column_types': {'col1': 'float', 'col2': 'int'}}
        target_type = config.get("target_type")
        columns = config.get("columns", [])
        column_types = config.get("column_types", {})

        final_map: dict[str, Any] = {}
        for col, dtype in column_types.items():
            if col in X.columns:
                final_map[col] = _resolve_casting_dtype(dtype)

        if target_type and columns:
            resolved_type = _resolve_casting_dtype(target_type)
            for col in columns:
                if col in X.columns:
                    final_map[col] = resolved_type

        categories = self._fit_categories(X, final_map)

        return {
            "type": "casting",
            "type_map": final_map,
            "coerce_on_error": config.get("coerce_on_error", True),
            "categories": categories,
            # New fits write 81.0 as "81" when casting to string; saved models replay as fitted.
            "integral_floats_as_int": True,
        }

    @staticmethod
    def _fit_categories(X: Any, type_map: dict[str, Any]) -> dict[str, list[Any]]:
        """Learn observed training categories without retaining unused vocabulary entries."""
        category_columns = [col for col, dtype in type_map.items() if dtype == "category"]
        categories: dict[str, list[Any]] = {}
        if category_columns:
            category_frame = select_then_to_pandas(X, category_columns)
            for col in category_columns:
                # Rebuild from observed values, excluding unused categories
                # that a sliced frame may have inherited from held-out rows.
                categories[col] = pd.Categorical(category_frame[col].to_numpy()).categories.tolist()

        return categories

fit(X, _y, config)

Merge the two config shapes into a type_map of columns present on X.

Absent columns are dropped rather than erroring, so a config written against a wider frame degrades to a partial cast; when both shapes name the same column, target_type overwrites the column_types entry.

Source code in skyulf-core/skyulf/preprocessing/casting.py
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
@fit_method
def fit(self, X: Any, _y: Any, config: dict[str, Any]) -> CastingArtifact:  # pylint: disable=arguments-differ
    """Merge the two config shapes into a ``type_map`` of columns present on *X*.

    Absent columns are dropped rather than erroring, so a config written
    against a wider frame degrades to a partial cast; when both shapes name
    the same column, ``target_type`` overwrites the ``column_types`` entry.
    """
    # Config supports two shapes:
    #   {'columns': ['col1'], 'target_type': 'float'}
    #   {'column_types': {'col1': 'float', 'col2': 'int'}}
    target_type = config.get("target_type")
    columns = config.get("columns", [])
    column_types = config.get("column_types", {})

    final_map: dict[str, Any] = {}
    for col, dtype in column_types.items():
        if col in X.columns:
            final_map[col] = _resolve_casting_dtype(dtype)

    if target_type and columns:
        resolved_type = _resolve_casting_dtype(target_type)
        for col in columns:
            if col in X.columns:
                final_map[col] = resolved_type

    categories = self._fit_categories(X, final_map)

    return {
        "type": "casting",
        "type_map": final_map,
        "coerce_on_error": config.get("coerce_on_error", True),
        "categories": categories,
        # New fits write 81.0 as "81" when casting to string; saved models replay as fitted.
        "integral_floats_as_int": True,
    }

infer_output_schema(input_schema, config)

Rewrite the dtype labels of input_schema for every column the config targets.

The shared target_type overrides per-column choices, matching fit. Columns absent from the input schema are skipped, as they are at runtime.

Source code in skyulf-core/skyulf/preprocessing/casting.py
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
def infer_output_schema(
    self, input_schema: SkyulfSchema, config: dict[str, Any]
) -> SkyulfSchema:
    """Rewrite the dtype labels of ``input_schema`` for every column the config targets.

    The shared ``target_type`` overrides per-column choices, matching fit.
    Columns absent from the input schema are skipped, as they are at runtime.
    """
    # Casting preserves the column set but rewrites dtype labels.
    column_types = dict(config.get("column_types", {}) or {})
    target_type = config.get("target_type")
    columns = config.get("columns", []) or []
    if target_type and columns:
        for col in columns:
            column_types[col] = target_type
    new_schema = input_schema
    for col, dtype in column_types.items():
        resolved = _resolve_casting_dtype(dtype)
        new_schema = new_schema.with_dtype(col, str(resolved))
    return new_schema