351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""Data type classifier for column-level type inference.
|
|
|
|
Provides :class:`DataType` enumeration and functions to classify Polars
|
|
columns based on name heuristics and value sampling.
|
|
|
|
Typical usage::
|
|
|
|
from analysis.type_classifier import classify_column, classify_schema, DataType
|
|
|
|
# Single column
|
|
dtype = classify_column('src_ip', series, config_type='ipv4')
|
|
|
|
# Full schema
|
|
type_map = classify_schema(lazyframe, config_overrides={'src_ip': 'ipv4'})
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from enum import Enum
|
|
|
|
import polars as pl
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DataType enumeration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class DataType(Enum):
|
|
"""Enumerated column types recognised by the classifier engine."""
|
|
|
|
INT = 1
|
|
FLOAT = 2
|
|
ENUM = 3
|
|
HEX = 4
|
|
URL = 5
|
|
IPv4 = 6
|
|
LAT_LON = 7
|
|
TIMESTAMP = 8
|
|
BOOL_ENUM = 9
|
|
STRING = 10
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Detection patterns (raw strings for regex)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
HEX_PATTERN = r'^[0-9a-f]{2}(?:\s[0-9a-f]{2})*$'
|
|
"""Matches ``"0e a6 3f"``, ``"ab cd ef"`` etc. (space-separated hex pairs)."""
|
|
|
|
URL_PATTERN = r'^https?://'
|
|
"""Matches URLs starting with ``http://`` or ``https://``."""
|
|
|
|
IPv4_PATTERN = r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'
|
|
"""Matches ``"192.168.1.1"`` style IPv4 addresses."""
|
|
|
|
MAC_PATTERN = r'^([0-9A-Fa-f]{2}[:.-]){5}[0-9A-Fa-f]{2}$'
|
|
"""Matches MAC addresses with ``:``, ``-``, or ``.`` separators."""
|
|
|
|
BOOL_ENUM_VALUES = (
|
|
'', ' ', '+', '-', '0', '1',
|
|
'true', 'false', 'yes', 'no', 't', 'f',
|
|
)
|
|
"""String values considered boolean / binary indicators."""
|
|
|
|
LAT_LON_NAMES = ('lat', 'latitude', 'lon', 'lng', 'longitude')
|
|
|
|
# Name-to-type heuristics (checked after config override, before values)
|
|
_NAME_TYPE_MAP: list[tuple[re.Pattern, DataType]] = [
|
|
# User column names - EXACT match only (no generic fuzzy patterns)
|
|
# IPv4 columns
|
|
(re.compile(r'^:ips$|^:ipd$|^server.ip$|^client.ip$', re.I), DataType.IPv4),
|
|
# ENUM columns
|
|
(re.compile(r'^:prs$|^:prd$', re.I), DataType.ENUM),
|
|
(re.compile(r'^(?:scnt|dcnt|cnrs|isrs|1ipp|4dbn)$', re.I), DataType.ENUM),
|
|
(re.compile(r'^0ver$', re.I), DataType.ENUM),
|
|
# FLOAT columns
|
|
(re.compile(r'^4dur$|^8ses$|^2tmo$|^0rnt$', re.I), DataType.FLOAT),
|
|
# INT columns
|
|
(re.compile(r'^4ksz$|^8ack$|^8ppk$|^row$', re.I), DataType.INT),
|
|
# HEX columns
|
|
(re.compile(r'^0cph$|^0crv$', re.I), DataType.HEX),
|
|
# TIMESTAMP columns
|
|
(re.compile(r'^8dbd$|^time$|^timestamp$', re.I), DataType.TIMESTAMP),
|
|
# Dot-notation suffixes for lat/lon (e.g. :ips.latd, :ipd.lond)
|
|
(re.compile(r'\.latd$', re.I), DataType.LAT_LON),
|
|
(re.compile(r'\.lond$', re.I), DataType.LAT_LON),
|
|
# Dot-notation STRING suffixes (e.g. :ips.ispn, :ipd.city)
|
|
(re.compile(r'\.(ispn|orgn|city)$', re.I), DataType.STRING),
|
|
# Standalone string columns - exact match
|
|
(re.compile(r'^(?:cnam|snam|tabl|name|0rnd|source.node|ecdhe.named.curve)$', re.I), DataType.STRING),
|
|
]
|
|
|
|
# Mapping from config.yaml type strings to DataType
|
|
_CONFIG_TYPE_MAP: dict[str, DataType] = {
|
|
'int': DataType.INT,
|
|
'float': DataType.FLOAT,
|
|
'enum': DataType.ENUM,
|
|
'hex': DataType.HEX,
|
|
'url': DataType.URL,
|
|
'ipv4': DataType.IPv4,
|
|
'lat_lon': DataType.LAT_LON,
|
|
'timestamp': DataType.TIMESTAMP,
|
|
'bool_enum': DataType.BOOL_ENUM,
|
|
'string': DataType.STRING,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Column classification (single column)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _name_to_type(name: str) -> DataType | None:
|
|
"""Match column name against known heuristics.
|
|
|
|
Returns a :class:`DataType` if the name matches strongly, otherwise
|
|
``None`` (fall through to value-based detection).
|
|
"""
|
|
for pattern, dtype in _NAME_TYPE_MAP:
|
|
if pattern.search(name):
|
|
return dtype
|
|
return None
|
|
|
|
|
|
def _is_port_value(v: str) -> bool:
|
|
"""Check if *v* is an integer string in the registered port range (1-65535)."""
|
|
try:
|
|
n = int(v)
|
|
return 1 <= n <= 65535
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _can_float(v: str) -> bool:
|
|
"""Return ``True`` when *v* can be parsed as a Python float."""
|
|
try:
|
|
float(v)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _values_to_type(series: pl.Series) -> DataType:
|
|
"""Infer column type by sampling non-null values.
|
|
|
|
Detection order: IPv4 → MAC guard → HEX → URL → PORT → BOOL_ENUM
|
|
→ LAT_LON → ENUM → FLOAT → STRING
|
|
"""
|
|
dtype = series.dtype
|
|
|
|
# Boolean dtype ──────────────────────────────────────────────────────
|
|
if dtype == pl.Boolean:
|
|
return DataType.BOOL_ENUM
|
|
|
|
# Numeric dtypes ─────────────────────────────────────────────────────
|
|
if dtype in (pl.Float32, pl.Float64):
|
|
# Check LAT_LON possibility before returning FLOAT
|
|
non_null = series.drop_nulls()
|
|
if len(non_null) > 0:
|
|
sample = non_null.head(100)
|
|
sample_strs = [str(v).strip().lower() for v in sample]
|
|
non_blank = [v for v in sample_strs if v not in ('', ' ', '+', '-')]
|
|
if non_blank:
|
|
try:
|
|
floats = [float(v) for v in non_blank]
|
|
max_abs = max(abs(f) for f in floats)
|
|
if (
|
|
all(-180 <= f <= 180 for f in floats)
|
|
and any('.' in v for v in non_blank)
|
|
and max_abs > 10
|
|
):
|
|
return DataType.LAT_LON
|
|
except ValueError:
|
|
pass
|
|
return DataType.FLOAT
|
|
|
|
if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
|
|
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64):
|
|
return DataType.INT
|
|
|
|
# String / categorical types ─────────────────────────────────────────
|
|
non_null = series.drop_nulls()
|
|
if len(non_null) == 0:
|
|
return DataType.STRING
|
|
|
|
sample = non_null.head(100)
|
|
# Convert all to string representations for pattern matching
|
|
sample_strs = [str(v).strip().lower() for v in sample]
|
|
|
|
# ── 1. IPv4 detection ──────────────────────────────────────────────
|
|
if all(re.match(IPv4_PATTERN, v) for v in sample_strs):
|
|
if all(_valid_ip_octets(v) for v in sample_strs):
|
|
return DataType.IPv4
|
|
|
|
# ── 2. MAC guard (skip HEX if values look like MAC addresses) ──────
|
|
is_mac = all(re.match(MAC_PATTERN, v) for v in sample_strs)
|
|
|
|
# ── 3. HEX detection ───────────────────────────────────────────────
|
|
if not is_mac and all(re.match(HEX_PATTERN, v) for v in sample_strs):
|
|
return DataType.HEX
|
|
|
|
# ── 4. URL detection ───────────────────────────────────────────────
|
|
if all(re.match(URL_PATTERN, v) for v in sample_strs):
|
|
return DataType.URL
|
|
|
|
# ── 5. PORT detection → ENUM ───────────────────────────────────────
|
|
if all(_is_port_value(v) for v in sample_strs):
|
|
return DataType.ENUM
|
|
|
|
# ── 6. BOOL_ENUM detection ─────────────────────────────────────────
|
|
if all(v in BOOL_ENUM_VALUES for v in sample_strs):
|
|
return DataType.BOOL_ENUM
|
|
|
|
# ── 7. LAT_LON detection (value-based) ──────────────────────────────
|
|
non_blank = [v for v in sample_strs if v not in ('', ' ', '+', '-')]
|
|
if non_blank:
|
|
try:
|
|
floats = [float(v) for v in non_blank]
|
|
max_abs = max(abs(f) for f in floats)
|
|
if (
|
|
all(-180 <= f <= 180 for f in floats)
|
|
and any('.' in v for v in non_blank)
|
|
and max_abs > 10
|
|
):
|
|
return DataType.LAT_LON
|
|
except ValueError:
|
|
pass
|
|
|
|
# ── 8. ENUM detection (few unique values) ──────────────────────────
|
|
unique_count = non_null.n_unique()
|
|
if unique_count < 20:
|
|
return DataType.ENUM
|
|
|
|
# ── 9. FLOAT detection (>80 % can float) ───────────────────────────
|
|
float_count = sum(1 for v in sample_strs if _can_float(v))
|
|
if float_count / len(sample_strs) > 0.8:
|
|
# Guard: values with leading "+"/"-" (e.g., "+5", "-abc") that
|
|
# happen to parse as float should not force a FLOAT classification
|
|
# when non-float values exist in the column. This catches string
|
|
# columns where a "+" prefix is used as a text marker (phone codes,
|
|
# IDs, error codes) rather than a numeric sign.
|
|
if any(v.startswith('+') or v.startswith('-') for v in sample_strs):
|
|
if float_count < len(sample_strs):
|
|
return DataType.STRING
|
|
return DataType.FLOAT
|
|
|
|
return DataType.STRING
|
|
|
|
|
|
def _valid_ip_octets(ip_str: str) -> bool:
|
|
"""Check that each octet in an IPv4 string is 0-255."""
|
|
parts = ip_str.split('.')
|
|
if len(parts) != 4:
|
|
return False
|
|
for p in parts:
|
|
try:
|
|
n = int(p)
|
|
if n < 0 or n > 255:
|
|
return False
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def classify_column(
|
|
name: str,
|
|
series: pl.Series,
|
|
config_type: str | None = None,
|
|
) -> DataType:
|
|
"""Classify a single column based on its *name* and *series* values.
|
|
|
|
Priority
|
|
--------
|
|
1. **config_type** — user override from ``config.yaml``.
|
|
If provided (e.g. ``'ipv4'``), returns the corresponding
|
|
:class:`DataType` immediately.
|
|
2. **Value-based** pattern matching — samples up to 100 non-null
|
|
values and tests against IPv4 → HEX → URL → PORT → BOOL_ENUM
|
|
→ LAT_LON → ENUM → FLOAT in order.
|
|
3. **Name-based** heuristics — only consulted when value-based
|
|
detection returned ``STRING`` (i.e. no value pattern matched).
|
|
|
|
Parameters
|
|
----------
|
|
name:
|
|
Column name (used for name-based heuristics).
|
|
series:
|
|
Polars :class:`Series` containing the column's data.
|
|
config_type:
|
|
Optional user override (case-insensitive, matched to
|
|
:data:`_CONFIG_TYPE_MAP`).
|
|
|
|
Returns
|
|
-------
|
|
DataType
|
|
"""
|
|
# 1. Config override (highest priority)
|
|
if config_type is not None:
|
|
mapped = _CONFIG_TYPE_MAP.get(config_type.lower().strip())
|
|
if mapped is not None:
|
|
return mapped
|
|
|
|
# 2. Value-based sampling
|
|
result = _values_to_type(series)
|
|
|
|
# 3. Name-based heuristics (only when values returned STRING)
|
|
if result == DataType.STRING:
|
|
name_match = _name_to_type(name)
|
|
if name_match is not None:
|
|
return name_match
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema classification (all columns)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def classify_schema(
|
|
lf: pl.LazyFrame,
|
|
config_overrides: dict[str, str] | None = None,
|
|
) -> dict[str, DataType]:
|
|
"""Classify all columns in a :class:`LazyFrame`.
|
|
|
|
Parameters
|
|
----------
|
|
lf:
|
|
A Polars :class:`LazyFrame` whose columns to classify.
|
|
config_overrides:
|
|
Optional mapping of ``{col_name: type_string}`` from a
|
|
``config.yaml`` ``columns:`` section. These take highest
|
|
priority (see :func:`classify_column`).
|
|
|
|
Returns
|
|
-------
|
|
dict[str, DataType]
|
|
Mapping from column name to inferred :class:`DataType`.
|
|
"""
|
|
config_overrides = config_overrides or {}
|
|
df_sample = lf.collect(streaming=True)
|
|
result: dict[str, DataType] = {}
|
|
for col_name in df_sample.columns:
|
|
config_type = config_overrides.get(col_name)
|
|
result[col_name] = classify_column(
|
|
name=col_name,
|
|
series=df_sample[col_name],
|
|
config_type=config_type,
|
|
)
|
|
return result
|