diff --git a/docs/examples/basic-examples.md b/docs/examples/basic-examples.md index c4726c7..656051e 100644 --- a/docs/examples/basic-examples.md +++ b/docs/examples/basic-examples.md @@ -60,14 +60,15 @@ features = { ), } -# Define cross-feature between 2 arbitrary features, though tabular attention would be more useful for feature crossings -feature_crosses = [("normalized_float", "rescaled_float", 10)] # 10 is the number of bins to hash into +# A cross hashes the pair of raw values, so both sides have to be discrete: +# every feature here is a float and none of them can be crossed. Example 2 +# crosses two categorical features; tabular attention is the tool for finding +# interactions between numeric ones. # Now we can create a preprocessing model with the features ppr = PreprocessingModel( path_data="sample_data.csv", features_specs=features, - feature_crosses=feature_crosses, features_stats_path="features_stats.json", overwrite_stats=True, output_mode=OutputModeOptions.CONCAT, diff --git a/docs/features/cross-features.md b/docs/features/cross-features.md index a7d19ad..b974869 100644 --- a/docs/features/cross-features.md +++ b/docs/features/cross-features.md @@ -30,9 +30,9 @@

Optimized for large-scale feature crosses

- ๐Ÿง  -

Smart Embeddings

-

Learn meaningful feature combinations

+ ๐ŸŽ›๏ธ +

Fixed Width

+

Each cross adds one column, whatever the cardinality

@@ -41,7 +41,7 @@
Cross Features Architecture
-

KDP's cross features combine input features through a sophisticated embedding process, creating rich representations of feature interactions.

+

KDP crosses two columns by hashing the pair of raw values into a fixed number of bins, and appends that bin index to the output as a single column.

@@ -53,15 +53,15 @@
- ๐Ÿ“Š -

Vocabulary Creation

-

Building a vocabulary of meaningful combinations

+ #๏ธโƒฃ +

Hashing

+

Mapping each pair into one of nr_bins buckets

๐Ÿงฎ -

Embedding Generation

-

Creating dense representations of combined features

+

One Extra Column

+

The bin index, appended to the categorical block

@@ -78,7 +78,8 @@ ```python from kdp import PreprocessingModel, FeatureType -# Define your features +# Define your features. Both sides of a cross must be categorical: the pair of +# raw values is hashed, so the columns have to be strings or integers. features = { "product_category": FeatureType.STRING_CATEGORICAL, "user_country": FeatureType.STRING_CATEGORICAL, @@ -90,12 +91,15 @@ preprocessor = PreprocessingModel( path_data="customer_data.csv", features_specs=features, - # Define crosses as (feature1, feature2, embedding_dim) + # Define crosses as (feature1, feature2, nr_bins) feature_crosses=[ - ("product_category", "user_country", 32), # Cross with 32-dim embedding - ("age_group", "user_country", 16) # Cross with 16-dim embedding + ("product_category", "user_country", 32), # pairs hashed into 32 bins + ("age_group", "user_country", 16) # pairs hashed into 16 bins ] ) + +# Each cross adds exactly one column to the output, holding the bin index of +# the (feature1, feature2) pair -- a value in [0, nr_bins). ```
@@ -115,33 +119,21 @@ preprocessor = PreprocessingModel( feature1 - First feature to cross + First feature to cross. Must be declared in features_specs and be a string or integer column - - Any feature name + Any categorical feature name feature2 - Second feature to cross + Second feature to cross, under the same rules - - Any feature name - - - embedding_dim - Dimensionality of cross embedding - 16 - 8-64 + Any categorical feature name - hash_bucket_size - Size of hash space for combinations - 10000 - 1000-100000 - - - use_attention - Apply attention to cross embeddings - False - Boolean + nr_bins + Number of hash buckets the pair is mapped into. Bigger means fewer collisions between distinct pairs + - + Around the number of pairs you expect to see @@ -174,21 +166,33 @@ preprocessor = PreprocessingModel(
-

Categorical ร— Numerical

-

Capture how numerical relationships change across categories:

+

Categorical ร— Bucketed Numerical

+

A numeric column cannot be crossed directly -- hashing needs discrete + values, and a float column is refused when the preprocessor is built. Bucket + it into a categorical column of your own first:

```python +import pandas as pd from kdp import FeatureType, PreprocessingModel -# Creating categorical ร— numerical crosses +# Turn the numeric column into bands, then cross the bands +frame = pd.read_csv("products.csv") +frame["price_band"] = pd.cut( + frame["price"], + bins=[0, 10, 50, 200, float("inf")], + labels=["budget", "standard", "premium", "luxury"], +).astype(str) +frame.to_csv("products_banded.csv", index=False) + preprocessor = PreprocessingModel( + path_data="products_banded.csv", features_specs={ "product_category": FeatureType.STRING_CATEGORICAL, - "price": FeatureType.FLOAT_RESCALED + "price_band": FeatureType.STRING_CATEGORICAL, }, feature_crosses=[ - ("product_category", "price", 32) + ("product_category", "price_band", 32) ] ) ``` @@ -197,23 +201,34 @@ preprocessor = PreprocessingModel(
-

Date Component Crosses

-

Useful for temporal patterns that depend on multiple time components:

+

Date Crosses

+

A DateFeature is one column that expands into cyclical + encodings inside the model; there are no separate + <name>_hour or <name>_day_of_week + features to cross. Derive the components you want to cross as their own + categorical columns:

```python -# Creating date component crosses -from kdp.features import DateFeature +import pandas as pd +from kdp import FeatureType, PreprocessingModel + +frame = pd.read_csv("transactions.csv") +stamps = pd.to_datetime(frame["transaction_time"]) +frame["transaction_day_of_week"] = stamps.dt.day_name() +frame["transaction_hour"] = stamps.dt.hour.astype(str) +frame.to_csv("transactions_parts.csv", index=False) preprocessor = PreprocessingModel( + path_data="transactions_parts.csv", features_specs={ - "transaction_time": DateFeature( - name="transaction_time" - ) + "transaction_time": FeatureType.DATE, + "transaction_day_of_week": FeatureType.STRING_CATEGORICAL, + "transaction_hour": FeatureType.STRING_CATEGORICAL, }, # Cross day of week with hour of day feature_crosses=[ - ("transaction_time_day_of_week", "transaction_time_hour", 16) + ("transaction_day_of_week", "transaction_hour", 16) ] ) ``` @@ -235,13 +250,13 @@ preprocessor = PreprocessingModel( "product_category": FeatureType.STRING_CATEGORICAL, "user_country": FeatureType.STRING_CATEGORICAL, "device_type": FeatureType.STRING_CATEGORICAL, - "user_age": FeatureType.FLOAT_NORMALIZED + "age_group": FeatureType.STRING_CATEGORICAL }, # Define multiple crosses to capture different interactions feature_crosses=[ ("product_category", "user_country", 32), ("device_type", "user_country", 16), - ("product_category", "user_age", 24) + ("product_category", "age_group", 24) ] ) ``` @@ -334,10 +349,14 @@ preprocessor = PreprocessingModel( ), "product_price_range": FeatureType.STRING_CATEGORICAL, - # Temporal features + # Temporal features. The date column feeds the model its cyclical + # encodings; the two categorical columns beside it are what the crosses + # use, because a cross needs discrete values. "browse_time": DateFeature( name="browse_time" - ) + ), + "browse_is_weekend": FeatureType.STRING_CATEGORICAL, + "browse_hour": FeatureType.STRING_CATEGORICAL }, # Define crosses for recommendation patterns @@ -349,10 +368,10 @@ preprocessor = PreprocessingModel( ("user_device", "product_price_range", 16), # Temporal ร— product (weekend browsing patterns) - ("browse_time_is_weekend", "product_category", 32), + ("browse_is_weekend", "product_category", 32), # Time of day ร— product (morning vs evening preferences) - ("browse_time_hour", "product_category", 32) + ("browse_hour", "product_category", 32) ] ) ``` @@ -385,10 +404,13 @@ preprocessor = PreprocessingModel( "user_country": FeatureType.STRING_CATEGORICAL, "account_age_days": FeatureType.FLOAT_NORMALIZED, - # Time features + # Time features, plus the discrete columns the crosses need: an hour + # band and an amount band derived from the raw columns above. "transaction_time": DateFeature( name="transaction_time" - ) + ), + "transaction_hour": FeatureType.STRING_CATEGORICAL, + "amount_band": FeatureType.STRING_CATEGORICAL }, # Cross features for fraud patterns @@ -396,14 +418,14 @@ preprocessor = PreprocessingModel( # Country ร— merchant (unusual combinations) ("user_country", "merchant_category", 32), - # Payment method ร— amount (unusual payment methods for large amounts) - ("payment_method", "transaction_amount", 24), + # Payment method ร— amount band (unusual methods for large amounts) + ("payment_method", "amount_band", 24), - # Time ร— amount (unusual times for large transactions) - ("transaction_time_hour", "transaction_amount", 24), + # Hour ร— amount band (unusual times for large transactions) + ("transaction_hour", "amount_band", 24), # Country ร— time (transactions from unusual locations at odd hours) - ("user_country", "transaction_time_hour", 32) + ("user_country", "transaction_hour", 32) ], # Enable tabular attention for additional interaction discovery @@ -420,11 +442,11 @@ preprocessor = PreprocessingModel(
graph TD - A1[Feature 1] --> C[Feature Combination] + A1[Feature 1] --> C[Pair the raw values] A2[Feature 2] --> C - C --> D[Hash/Lookup] - D --> E[Embedding Layer] - E --> F[Cross Representation] + C --> D[Hash into nr_bins buckets] + D --> E[Cast the bin index to float32] + E --> F[One extra output column] style A1 fill:#e3f2fd,stroke:#64b5f6,stroke-width:2px style A2 fill:#e3f2fd,stroke:#64b5f6,stroke-width:2px @@ -434,7 +456,7 @@ preprocessor = PreprocessingModel( style F fill:#e8eaf6,stroke:#7986cb,stroke-width:2px
-

KDP combines features, creates a vocabulary or hash space for combinations, and embeds these into dense representations to capture meaningful interactions.

+

KDP pairs the two raw values, hashes the pair into one of nr_bins buckets, and appends that bin index to the output as a single float column alongside the categorical features.

@@ -454,22 +476,22 @@ preprocessor = PreprocessingModel(

โš ๏ธ Beware of Sparsity

-

Crosses between high-cardinality features can create sparse combinations:

+

Crosses between high-cardinality features produce many distinct pairs, and nr_bins decides how many of them share a bucket:

    -
  • Use embeddings (default in KDP) rather than one-hot encoding
  • -
  • Consider hashing for very high cardinality crosses
  • -
  • Use category_encoding="hashing" for feature types with many values
  • +
  • Too few bins and unrelated pairs collide into one value
  • +
  • Too many and most bins are never seen by the model
  • +
  • The columns feeding a cross can themselves use category_encoding="hashing" when they have many values
-

๐Ÿ“ Cross Dimensionality

-

Choose embedding dimension based on cross importance and complexity:

+

๐Ÿ“ Choosing nr_bins

+

The third element of the tuple is the number of hash buckets, not an embedding size:

    -
  • More important crosses deserve higher dimensionality
  • -
  • Simple crosses: 8-16 dimensions
  • -
  • Complex crosses: 32-64 dimensions
  • -
  • Rule of thumb: โดโˆš(possible combinations)
  • +
  • Start near the number of pairs you actually expect to see
  • +
  • Small crosses (a handful of categories each): 8-32 bins
  • +
  • Larger crosses: a few times the distinct pair count, to keep collisions rare
  • +
  • The output width is one column per cross whatever you choose
@@ -478,8 +500,8 @@ preprocessor = PreprocessingModel(

Consider other interaction modeling techniques alongside crosses:

  • Enable tabular_attention=True to automatically discover interactions
  • -
  • Use transformer_blocks for more sophisticated feature relationships
  • -
  • Try dot-product interactions for numerical features
  • +
  • Use transfo_nr_blocks for more sophisticated feature relationships
  • +
  • Bucket a numeric column into bands to bring it into a cross
diff --git a/docs/features/date-features.md b/docs/features/date-features.md index 0f07ce0..58b73a7 100644 --- a/docs/features/date-features.md +++ b/docs/features/date-features.md @@ -47,7 +47,7 @@ preprocessor = PreprocessingModel( "signup_date": DateFeature( name="signup_date", feature_type=FeatureType.DATE, - format="YYYY-MM-DD", # or "YYYY/MM/DD" + format="YYYY-MM-DD", # or "YYYY/MM/DD", each with an optional time add_season=True, # append a 4-dim one-hot season ), }, @@ -59,7 +59,8 @@ preprocessor.build_preprocessor() ## โš™๏ธ Configuration Parameters -`DateFeature` takes exactly two options. Anything else you pass is ignored. +`DateFeature` takes exactly two options. Anything else you pass is accepted +and not used, and says so in a warning.
@@ -76,7 +77,7 @@ preprocessor.build_preprocessor() - + @@ -93,8 +94,9 @@ preprocessor.build_preprocessor() `add_day_of_week`, `add_hour`, `add_is_weekend`, `add_quarter`, `cyclical_encoding`, `add_time_since_reference`, `reference_date` and `time_since_unit`. None of them are read by KDP. `DateFeature` accepts - arbitrary keyword arguments without complaint, so passing them looks like - it works and silently changes nothing. Year, month, day of month and day + arbitrary keyword arguments, and only `output_format` and `extract` are + called out in a warning; the rest pass without a word and change nothing. + Year, month, day of month and day of week are **always** extracted and **always** cyclically encoded; that is not configurable. For anything beyond that, use a [custom preprocessing pipeline](../advanced/custom-preprocessing.md). diff --git a/docs/features/text-features.md b/docs/features/text-features.md index 9bc2d9d..d05c101 100644 --- a/docs/features/text-features.md +++ b/docs/features/text-features.md @@ -72,19 +72,19 @@ apart from `stop_words`, which KDP applies itself beforehand. - + - + - +
format str "YYYY-MM-DD"Layout of the date string. Only YYYY-MM-DD and YYYY/MM/DD are supported; anything else raises at parse time.Layout of the date string. Dates are read as year, then month, then day, separated by - or /, and may be followed by a time -- "%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S" and "YYYY-MM-DD" all describe a column this reads. A day-first or month-first format is refused where you write it. date_format is accepted as a synonym.
add_seasonmax_tokens int —Caps the vocabulary. Must be at least as large as the vocabulary found in your data, or Keras raises.Caps the vocabulary, counting the out-of-vocabulary slot (and the padding slot under "int"). When the cap is smaller than the vocabulary the statistics found, the vectorizer is adapted on the column so the words kept are the most frequent ones.
ngrams int | tuple NoneGenerate n-grams in addition to single tokens.Generate n-grams in addition to single tokens. The statistics collect single words only, so setting this reads the column again to build a vocabulary that holds the n-grams.
split, standardize str | callable Keras defaultsPassed straight through to TextVectorization.Passed straight through to TextVectorization. Either one changes what a token is, so setting them also reads the column again rather than reusing the collected vocabulary.
@@ -230,8 +230,8 @@ preprocessor = PreprocessingModel(

When only presence matters — tags, short titles — "multi_hot" is smaller and easier to learn from than a padded sequence.

-

max_tokens must fit the data

-

Keras raises if the cap is below the vocabulary actually found. Set it generously or leave it unset.

+

Tokens are standardized before they are counted

+

Text is lowercased and stripped of punctuation before it is split on whitespace, so "Great product," contributes great and product. That is TextVectorization's own default, and the vocabulary collected from your data is spelled to match it.

diff --git a/docs/features/time_series_features.md b/docs/features/time_series_features.md index f4b6681..1087a6f 100644 --- a/docs/features/time_series_features.md +++ b/docs/features/time_series_features.md @@ -335,7 +335,7 @@ preprocessor = PreprocessingModel( tsfresh_features - Statistical features to extract + Statistical features to extract. Needs a window to summarize, so pair tsfresh_feature_config with lag_config, rolling_stats_config or moving_average_config ["mean", "std", "min", "max", "median"] List of statistical features to compute @@ -506,10 +506,17 @@ preprocessor = PreprocessingModel( ```python from kdp import TimeSeriesFeature, PreprocessingModel -# Define a feature with statistical features extraction +# Define a feature with statistical features extraction. +# +# The statistics summarize a window, and a time series feature hands one value +# per row over, so something has to widen it first: the lags below give each row +# its own recent history to summarize. Without one of `lag_config`, +# `rolling_stats_config` or `moving_average_config` there is a single step in +# the window and every statistic is degenerate, which the layer refuses. ecg_data = TimeSeriesFeature( name="ecg_signal", sort_by="timestamp", + lag_config={"lags": list(range(1, 100)), "drop_na": False}, # Statistical feature extraction tsfresh_feature_config={ "features": [ diff --git a/docs/getting-started/upgrading.md b/docs/getting-started/upgrading.md index f291c24..ff09b7e 100644 --- a/docs/getting-started/upgrading.md +++ b/docs/getting-started/upgrading.md @@ -24,6 +24,14 @@ configuration changes, but the numbers coming out of the preprocessor do. | `AutoLagSelectionLayer` on multi-channel input | Lags were chosen from the autocorrelation of channel 0 alone, then applied to every channel, so reordering the columns of the same data changed the result | The autocorrelation is averaged over the channels as well as the batch, so every channel contributes and the choice does not depend on column order | | `TimeSeriesFeature.get_output_dim()` on wavelet, tsfresh or calendar configs | Counted those transforms as columns added to the series. They replace it: the wavelet returns coefficients, tsfresh returns statistics, the calendar returns date components. The declared width matched no configuration at all | Each layer is asked for its own width, so the number matches the tensor the stack produces | | `DateFeature(add_season=True)` | The season layer ran after the cyclic encoding and read its column 1 โ€” the cosine of the year, `1.0` for every row โ€” as the month, so every date came out winter and the four season columns were constant | The season is read from the month before the encoding replaces it, so the four columns carry the season. The output is the same width as before | +| Any numeric column far from zero | The variance was accumulated in float32 by subtracting the running mean from each raw value, so the arithmetic happened at the magnitude of the values. A column around `1e8` came back with a variance of **minus** 1.6e6 against a true 1.0e6, and `Normalization` then flattened it to a constant | Accumulated in float64 and pooled batch by batch, so IDs, epoch timestamps and amounts in cents standardise to mean 0 and variance 1 like any other column | +| `path_data` pointing at a file | The filename was discarded and every `*.csv` in that directory was read, so `path_data="data/train.csv"` computed its statistics over `test.csv` as well | The named file is the only one read. A directory still reads every CSV in it, and a glob is passed through | +| Grouped time series statistics | Each group was replaced by copies of its own mean before combining, leaving only the variance *between* the group means โ€” 0.73 against a true 643 for two groups of spread 25 | The groups are pooled whole, so the reported variance is the variance of the data | +| `DistributionTransformLayer` โ€” `min-max` | `min_value` and `max_value` were read as the range the data was assumed to be *in* rather than the range to scale it *onto*, so with their defaults of 0 and 1 the transform returned its input untouched. `clip_values` chose between that and the data's own range instead of clipping anything | Each feature's own range is scaled onto `[min_value, max_value]`, and `clip_values` clips the result into it. `min-max` is also one of the candidates `transform_type="auto"` can pick, so an auto-selected column may change too | +| `DistributionTransformLayer` โ€” `log` | `log(x + epsilon)` with no guard on the domain, so any negative value returned **NaN**, which then travelled through every layer and every gradient downstream without a message | Clamped at zero first, the way the neighbouring `sqrt` and `box-cox` already were, so a negative value lands on the floor of the transform rather than outside it | +| `DistributionTransformLayer` โ€” `logit` | Clipped into `[epsilon, 1 - epsilon]`, but the default epsilon of `1e-10` is far below the float32 gap at 1.0, so the upper bound rounded back to exactly 1.0 and every value at or above 1 came out as **+inf** | The margin is at least one float32 step wide, so both ends of the clip bite | +| `DistributionTransformLayer` โ€” `arcsinh` | Spelled out as `log(x + sqrt(x*x + 1))`, which loses the whole negative tail: at `x = -1e6` the square is `1e12`, float32 cannot hold the `+1`, the root is exactly `1e6`, the sum is exactly zero and the logarithm is **-inf** | Computed with `asinh`, which is the same function without the cancellation | +| Text vocabularies | The statistics lowercased and split on whitespace but kept punctuation, while `TextVectorization` strips punctuation before splitting. The vocabulary held `product,` and `it!` while the layer looked up `product` and `it`, so on ordinary prose roughly **half of every sentence fell into the out-of-vocabulary slot** โ€” with the right output width and correct-looking counts | The statistics standardize exactly as the layer does, so the vocabulary collected is the one that gets looked up | | `DistributionTransformLayer` โ€” `robust-scale` | Median and IQR were taken across the whole tensor | Computed per feature, as the transform is defined | | `DistributionTransformLayer` โ€” `quantile` | Ranking was global | Ranked per feature | | Distribution-aware encoding | `preferred_distribution` was accepted and then ignored, so the layer always auto-detected | The requested distribution is honoured | @@ -34,11 +42,46 @@ configuration changes, but the numbers coming out of the preprocessor do. | `feature_moe_freeze_experts` | Passed `training=False` to the experts, which only changes dropout and batch norm; the weights still received gradients | The experts are marked non-trainable, which is what the option says | | `feature_moe_sparsity` | The top-k mask that enforces it sat behind `routing_activation != "softmax"`, which `PreprocessingModel` never exposes, so every feature was routed densely to every expert | Each feature reaches at most `feature_moe_sparsity` experts, as documented. With the defaults (4 experts, sparsity 2) a learned-routing model produces different values than it did | +!!! danger "Statistics change for two common shapes of data" + If any numeric column sits far from zero โ€” an ID, a UNIX timestamp, an + amount in cents โ€” its statistics were wrong and the column reached your + model as a constant. It now carries its signal, which changes that + column's output completely. + + Separately, if you passed `path_data` a **file** in a directory holding + other CSVs, the statistics were computed over all of them. If that + directory held your test set, it was folded into the training statistics. + Recompute with `overwrite_stats=True`; any `features_stats.json` written + before this release may be built from the wrong rows. + !!! warning "Re-train downstream models" If you trained a model on top of KDP output from 1.11.x and any of the rows above apply to your feature set, the preprocessor now feeds it different numbers. Re-fit before you compare metrics. +## โž• `feature_crosses` reaches the output + +Every cross was built and then dropped. `_group_features_by_type` looks each +processed feature up in `features_specs`, and a cross is configured with +`feature_crosses` rather than by declaring a column, so the lookup found +nothing and skipped it. In the default `concat` output mode the option did +nothing at all: the model built, ran, and had exactly the width it would have +had with no crosses. + +Each cross now appends one column โ€” the bin the `(feature_a, feature_b)` pair +hashes into โ€” alongside the categorical features. **A model that set +`feature_crosses` is wider than it was**, so re-train anything fitted on the +old output. + +Two configurations that used to fail later now fail where you wrote them: + +- Crossing a float column. `HashedCrossing` hashes raw values and accepts only + integers and strings; a float built a model that looked complete and raised + on the first batch it was given. Bucket the column into a categorical one + first. +- Naming a feature that is not in `features_specs`, which surfaced as a bare + `KeyError`. + ## ๐Ÿงฉ Feature MoE routed slices, not features Three defects compounded here, and together they meant the mixture was rarely @@ -173,6 +216,60 @@ step to work on now raises instead of emitting a constant column of zeros: combine it with `lag_config`, `rolling_stats_config` or `moving_average_config` so there is a window to decompose. +## ใ€ฐ๏ธ `tsfresh_feature_config` needs a window too + +The same shape as the wavelet, one section up. A time series feature hands one +column per row over, `window_size` is clamped to the number of steps available, +and every statistic over a window of one is degenerate: the mean, the minimum +and the maximum are all the value itself and the standard deviation is zero. A +`tsfresh_feature_config` asking for four statistics therefore returned the input +column three times plus a column of zeros -- four engineered features carrying +nothing, produced without a word. It raises now, naming the configs that widen +the feature; paired with any of them the statistics match `pandas.rolling` +exactly. + +## ๐Ÿ”ข The column order no longer moves between builds + +Features are preprocessed in parallel batches, and the concatenated output was +assembled by walking a dict keyed in whichever order the workers happened to +finish. Building the same configuration twice laid the columns out differently +โ€” five distinct layouts in six builds of the same six features. Nothing raised, +and each build's numbers were right for the layout it chose. + +The damage lands on anyone who trains a model on one build of the preprocessor +and serves it from another: every feature is read as a different one. Reloading +a saved `.keras` file was never affected, because the order is baked into its +graph. + +Columns now follow the order features are declared in โ€” numeric first, then +categorical, then any crosses โ€” and `processed_features_dims` reports the same +order. **If you recorded a column-to-feature mapping from an earlier build, +take it again.** + +## ๐Ÿ“† Calendar time series features can be built + +The documented way to ask for calendar components is a `TimeSeriesFeature` +whose `calendar_feature_config` names them. Its column holds dates, and +everything it produces is derived from that string by `CalendarFeatureLayer` -- +there is no mean or variance to take. The statistics pass fed the column to the +numeric accumulator anyway and died inside a `tf.function` with `Cast string to +double is not supported`, so the whole configuration could not be built. The +layer had tests; the path from `PreprocessingModel` to it did not. + +Calendar columns are skipped in the numeric statistics now. With +`normalize=False` every component matches what pandas reads off the same +column; with the default `normalize=True` they are scaled into `[0, 1]`. + +## ๐Ÿ’พ A `tf_idf` text model can be reloaded + +Saving worked; loading raised `object of type 'bool' has no len()`. Keras writes +a `tf_idf` vectorizer's IDF weights as layer variables and, on load, assigns +them to a layer that has no vocabulary yet and therefore no such variable. It +happens with `TextVectorization` alone, with no KDP in the picture. The +vocabulary and the weights are now passed to the constructor, where the config +carries them and loading reads them; the layer computes the same numbers either +way. + ## ๐ŸŽฏ Feature importances rank features now `get_feature_importances()` returned `1.0` for every feature, on every input. @@ -199,6 +296,57 @@ importances = preprocessor.get_feature_importances(batch) Each selected feature is scaled by its score, where before it was multiplied by `1.0`. A model trained on the old output should be retrained. +## ๐Ÿ“ The output is always a table + +A transformer block adds a sequence axis to a 2-D input and hands it back. The +`categorical` placement removed it again; `transfo_placement="all_features"` +did not, so that one configuration returned `(rows, 1, width)` while every +other returned `(rows, width)` โ€” enough to break a `Dense` head bolted onto the +preprocessor, decided by a flag that says nothing about shape. + +Every configuration now returns `(rows, width)`. If you added a `Reshape` or a +`Flatten` after the preprocessor to work around this, remove it. + +## ๐Ÿงพ `predict()` takes the shapes it documents + +Its docstring offers "pandas DataFrame, dict, or TensorFlow dataset". A +DataFrame went straight to Keras, which reads a frame as a block of floats and +raised `could not convert string to float` on the first categorical column. A +dict of flat lists โ€” the natural spelling, and exactly what +`InferenceFormatter` produces โ€” converted to shape `(N,)` where every input is +declared `(N, 1)`, and failed with `as_list() is not defined on an unknown +TensorShape`. Both carried the right values; only the container was wrong. + +
+ +```python +# All four of these now give the same tensor. +preprocessor.predict(frame) +preprocessor.predict({"age": frame["age"].tolist(), "city": frame["city"].tolist()}) +preprocessor.predict({"age": [[35.0]], "city": [["paris"]]}) +preprocessor.predict(tf.data.Dataset.from_tensor_slices(dict(frame)).batch(32)) +``` + +
+ +## ๐Ÿ”ค `max_tokens` and `ngrams` on a text feature + +Both were accepted and neither could work, because the vocabulary handed to +`TextVectorization` came from the statistics pass, which collects single +standardized words: + +- `max_tokens` below the vocabulary the data holds was refused by Keras with + "Attempted to set a vocabulary larger than the maximum vocab size", so the + only reason to set the option โ€” asking for a smaller vocabulary โ€” could not + build. A larger one worked and did nothing. +- `ngrams` asked for word pairs the statistics never recorded, so every n-gram + landed in the single out-of-vocabulary slot. The output width did not move. + +Either option, and a custom `standardize` or `split`, now reads the column +again and lets the layer build the vocabulary it will actually use โ€” the same +thing `output_mode="tf_idf"` already did. **A model that set `ngrams` is wider +than it was.** + ## ๐Ÿšซ Configurations that are now rejected These configurations used to be accepted and then silently do the wrong thing. @@ -231,6 +379,19 @@ PreprocessingModel( An expert index outside the mixture, or a name the mixture never sees, is rejected the same way. +A date format the parser cannot read is the next. `DateFeature` never read the +format string it was given, so `format="%d-%m-%Y"` was accepted, ignored, and +met again at the first batch as an assertion failure inside a TensorFlow graph +error, far from the line that caused it. Dates are read as year, then month, +then day; anything else raises where you write it. `date_format` is accepted as +a synonym for `format`, and `output_format` and `extract` โ€” which nothing reads +โ€” now say so in a warning. + +The same parser gained a capability rather than losing one: a time after the +date is dropped instead of rejected, so `"2021-06-15 13:45:00"` and its ISO +`T` spelling are read as the date they carry. A timestamp column previously had +no way through the layer at all. + A categorical feature whose column holds no values at all is the other case. Its vocabulary came back empty and was replaced with `[""]`, which for a string feature encoded every real value to the out-of-vocabulary slot โ€” a constant @@ -454,8 +615,13 @@ vocabularies as byte reprs โ€” the literal string `"b'paris'"` rather than **every category missed the vocabulary and encoded identically to an unseen value**. -KDP now detects that format and recomputes from the data instead of trusting -it, logging a warning that names the file. You do not have to do anything, +Files written before this release are also missing the `min`, `max`, +`skewness` and `kurtosis` entries the model advisor reads; a stats file without +them still builds a preprocessor, but the advisor falls back to its neutral +defaults. Pass `overwrite_stats=True` once to collect them. + +KDP now detects the byte-repr format and recomputes from the data instead of +trusting it, logging a warning that names the file. You do not have to do anything, but be aware that: - the first build after upgrading re-reads your dataset, so it takes as long @@ -464,6 +630,61 @@ but be aware that: To recompute deliberately instead, pass `overwrite_stats=True` once. +## ๐Ÿ•ณ๏ธ Missing values reaching `predict()` + +A frame with a gap in it raised `Invalid dtype: object` โ€” pandas gives a column +mixing `NaN` with strings the object dtype, and Keras refuses that outright. +`predict()` now takes a DataFrame apart the way `InferenceFormatter` does, +deciding each column's type from the values that are actually there, so the two +paths agree. + +What each kind of gap becomes: + +| Column | A missing value becomes | +|--------|-------------------------| +| Categorical, text | the empty string, which every vocabulary layer maps to its out-of-vocabulary slot | +| Numeric | `NaN`, which normalization carries through to that one column of that one row | +| Date | refused โ€” see below | + +The `NaN` is deliberate: the statistics know the column's mean, but filling it +in would hide a hole in your data behind a plausible number. It stays inside +the feature it came from โ€” the rest of the row, and the rest of the batch, are +unaffected โ€” so you can find it with `np.isfinite` on the output. Fill the +column yourself before preprocessing if you want something else. + +A date is the exception: there is no neutral date, so a value the parser cannot +read stops the batch rather than being invented. It stopped it before too, as a +TensorFlow graph error naming an internal assertion node; the message now says +what was expected, that an empty value counts as invalid, and prints the value +that failed. Fill or drop those rows before preprocessing. + +## ๐Ÿ“ˆ The advisor can tell distributions apart + +`ModelAdvisor` reads `skewness`, `kurtosis`, `min` and `max` off each numeric +feature, and the statistics carried none of them. Every column arrived with the +neutral defaults โ€” skew 0, kurtosis 3 โ€” and came back as "Normal distribution +detected, standard normalization recommended" whatever shape it had, while the +rescaling factor derived from `min` and `max` always worked out to exactly 1. + +The statistics now collect all four, pooled batch by batch in float64 like the +mean and variance, so the advisor's existing logic reaches its heavy-tailed, +log-normal and uniform branches for the first time. The generated code snippet +also carries a `path_data` line: without it, pasting the advice raised, because +every configuration the advisor recommends needs the data to build. + +## โš ๏ธ Columns float32 cannot hold + +Everything after the CSV reader is float32, which carries about seven +significant digits. A column whose values sit far from zero and vary only +slightly loses that variation on the way in: Unix timestamps in seconds are 128 +apart in float32 near `1.6e9`, so a column spread over half a minute arrives as +one or two distinct numbers. Normalization then works perfectly on what is +left, reports a standard deviation of 1.0, and hands the model a constant. + +The loss happens before any statistic is computed and cannot be undone there, +so this is a warning naming the column, not a change in behaviour. Subtract a +reference point before training, or declare the column as a `DATE` feature. + ## ๐Ÿ Requirements
diff --git a/kdp/features.py b/kdp/features.py index a2237bd..e41e3db 100644 --- a/kdp/features.py +++ b/kdp/features.py @@ -387,6 +387,64 @@ def __init__( self.kwargs = kwargs +# Where each calendar component appears in a format string, in both the +# strftime spelling and the plain one. +_YEAR_TOKENS = ("%Y", "%y", "YYYY", "YY") +_MONTH_TOKENS = ("%m", "%b", "%B", "MM") +_DAY_TOKENS = ("%d", "DD") + +# Options a `DateFeature` is given that nothing reads. The date encoding is +# fixed -- year, month and day, each as a sine and cosine pair -- so there is no +# component to select. +_UNREAD_DATE_OPTIONS = ("output_format", "extract") + + +def _first_index(text: str, tokens: tuple[str, ...]) -> int | None: + """Where the earliest of `tokens` appears in `text`, if any. + + Args: + text: The format string to search. + tokens: The spellings of one calendar component. + + Returns: + The index of the earliest match, or None when none appears. + """ + found = [text.index(token) for token in tokens if token in text] + return min(found) if found else None + + +def _check_date_format(name: str, date_format: str) -> None: + """Refuse a format the parser cannot read, while the caller can still fix it. + + The parser reads year, then month, then day, with `-` or `/` between them + and an optional time after. It does not read the format string, so a + day-first format was accepted here, ignored, and then met at the first batch + as an assertion failure inside a graph error -- long after the line that + caused it. + + Args: + name: The feature's name, for the message. + date_format: The format the caller gave. + + Raises: + ValueError: If the format does not put year, then month, then day. + """ + year = _first_index(date_format, _YEAR_TOKENS) + month = _first_index(date_format, _MONTH_TOKENS) + day = _first_index(date_format, _DAY_TOKENS) + + if None not in (year, month, day) and year < month < day: + return + + raise ValueError( + f"{name}: format={date_format!r} is not a format this library reads. " + f"Dates are parsed as year, then month, then day, separated by '-' or " + f"'/', optionally followed by a time -- '%Y-%m-%d', '%Y/%m/%d' and " + f"'%Y-%m-%d %H:%M:%S' among them. Convert the column to one of those " + f"before passing it in.", + ) + + class DateFeature(Feature): """TextFeature with dynamic kwargs passing.""" @@ -405,6 +463,25 @@ def __init__( """ super().__init__(name, feature_type, **kwargs) self.dtype = tf.string + + # `date_format` is the spelling most callers reach for, and the one the + # layer's own argument is called; the pipeline reads `format`. It was + # accepted under either name and honoured under neither. + if "date_format" in kwargs and "format" not in kwargs: + kwargs["format"] = kwargs["date_format"] + + if kwargs.get("format"): + _check_date_format(name, kwargs["format"]) + + for option in _UNREAD_DATE_OPTIONS: + if option in kwargs: + logger.warning( + f"{name}: {option}={kwargs[option]!r} is accepted and not " + f"used. A date feature always produces the same eight " + f"columns -- year, month and day, each as a sine and a " + f"cosine -- plus four more when add_season=True.", + ) + self.kwargs = kwargs diff --git a/kdp/layers/date_parsing_layer.py b/kdp/layers/date_parsing_layer.py index 1a57896..c80baf8 100644 --- a/kdp/layers/date_parsing_layer.py +++ b/kdp/layers/date_parsing_layer.py @@ -8,9 +8,10 @@ def __init__(self, date_format: str = "YYYY-MM-DD", **kwargs) -> None: """Initializing DateParsingLayer. Args: - date_format (str): Accepted and not used. Both supported - spellings, YYYY-MM-DD and YYYY/MM/DD, are parsed whatever this - says: the separator is normalised before the date is read. + date_format (str): Accepted and not used. Every supported + spelling -- YYYY-MM-DD and YYYY/MM/DD, each optionally followed + by a time -- is parsed whatever this says: the separator is + normalised and any time dropped before the date is read. kwargs (dict): other params to pass to the class. """ super().__init__(**kwargs) @@ -29,17 +30,38 @@ def call(self, inputs: tf.Tensor) -> tf.Tensor: """ def parse_date(date_str: str) -> tf.Tensor: - # Handle missing/invalid dates + # Handle missing/invalid dates. A time part is allowed after the + # date -- "2021-06-15 13:45:00" and its ISO "T" spelling are what a + # timestamp column holds, and rejecting them left a very ordinary + # kind of column with no way through this layer at all: the model + # built, then died on the first batch inside a graph error deep + # enough to bury the reason. is_valid = tf.strings.regex_full_match( date_str, - r"^\d{1,4}[-/]\d{1,2}[-/]\d{1,2}$", + r"^\d{1,4}[-/]\d{1,2}[-/]\d{1,2}([ T].*)?$", ) - tf.debugging.assert_equal( + # `tf.Assert` rather than `assert_equal`, so the value that failed + # can be printed alongside the reason. The old assertion surfaced + # as a graph error naming an internal node and nothing else, which + # is all a caller saw when one row held an empty string or a null. + tf.Assert( is_valid, - True, - message="Invalid date format. Expected YYYY-MM-DD or YYYY/MM/DD", + [ + tf.constant( + "Invalid date value. Expected YYYY-MM-DD or " + "YYYY/MM/DD, optionally followed by a time. An empty " + "value counts: a date column cannot hold nulls, so " + "fill or drop those rows before preprocessing. The " + "value that failed was:", + ), + date_str, + ], ) + # Everything below is built from the calendar date, so drop any + # time that came with it. + date_str = tf.strings.regex_replace(date_str, r"[ T].*$", "") + # First, standardize the separator to '-' in case of YYYY/MM/DD format date_str = tf.strings.regex_replace(date_str, "/", "-") diff --git a/kdp/layers/distribution_transform_layer.py b/kdp/layers/distribution_transform_layer.py index 04408d4..cb088aa 100644 --- a/kdp/layers/distribution_transform_layer.py +++ b/kdp/layers/distribution_transform_layer.py @@ -7,8 +7,13 @@ from typing import Any from loguru import logger from keras import ops, KerasTensor +import numpy as np import tensorflow as tf +# The gap between 1.0 and the next float32 below it. A clipping margin narrower +# than this rounds away, leaving the bound it was meant to hold open. +_FLOAT32_STEP = float(np.finfo(np.float32).eps) + @keras.saving.register_keras_serializable(package="kdp.layers") class DistributionTransformLayer(keras.layers.Layer): @@ -29,9 +34,10 @@ class DistributionTransformLayer(keras.layers.Layer): lambda_param: Parameter for parameterized transformations like Box-Cox and Yeo-Johnson. Default is 0.0. epsilon: Small value added to prevent numerical issues like log(0). Default is 1e-10. - min_value: Minimum value for min-max scaling. Default is 0.0. - max_value: Maximum value for min-max scaling. Default is 1.0. - clip_values: Whether to clip values to the specified range in min-max scaling. Default is True. + min_value: Low end of the range 'min-max' scales onto. Default is 0.0. + max_value: High end of the range 'min-max' scales onto. Default is 1.0. + clip_values: Whether 'min-max' clips its result into + [min_value, max_value]. Default is True. auto_candidates: list of transformation types to consider when transform_type is 'auto'. If None, all available transformations will be considered. Default is None. name: Optional name for the layer. @@ -458,8 +464,14 @@ def apply_none() -> tf.Tensor: return x def apply_log() -> tf.Tensor: - # Add epsilon to avoid log(0) - return tf.math.log(x + self.epsilon) + # `log` is defined for positive values only. Every neighbouring + # transform handles its own domain -- `sqrt` clamps at zero, + # `box-cox` at epsilon -- but this one handed negatives straight to + # `log` and returned NaN, which then spreads through the rest of the + # model and every gradient taken from it, silently. Clamp the same + # way `box-cox` does, so a negative value lands on the floor of the + # transform rather than outside it. + return tf.math.log(tf.maximum(x, 0.0) + self.epsilon) def apply_sqrt() -> tf.Tensor: # Ensure non-negative values @@ -510,9 +522,16 @@ def apply_yeo_johnson() -> tf.Tensor: ) def apply_arcsinh() -> tf.Tensor: - # Inverse hyperbolic sine transformation - # Works well for both positive and negative values with heavy tails - return tf.math.log(x + tf.sqrt(tf.square(x) + 1.0)) + # Inverse hyperbolic sine transformation. + # Works well for both positive and negative values with heavy tails. + # + # Spelled out as `log(x + sqrt(x**2 + 1))` this loses the whole + # negative tail: for x = -1e6 the square is 1e12, where float32 + # cannot hold the +1, its root is exactly 1e6, the sum is exactly + # zero and the logarithm is -inf. `x**2` also overflows to infinity + # well before x does. `asinh` computes the same function without + # either step. + return tf.math.asinh(x) def apply_cube_root() -> tf.Tensor: # Cube root transformation @@ -534,27 +553,53 @@ def apply_cube_root() -> tf.Tensor: ) def apply_logit() -> tf.Tensor: - # Logit transformation: log(x / (1 - x)) - # Clip values to the valid range with a small epsilon - x_clipped = tf.clip_by_value(x, self.epsilon, 1.0 - self.epsilon) + # Logit transformation: log(x / (1 - x)), which needs x strictly + # inside (0, 1). + # + # The margin has to survive the arithmetic that follows. The default + # epsilon of 1e-10 is far below the float32 gap at 1.0, so + # `1 - epsilon` rounded straight back to 1.0: the upper clip did + # nothing, `1 - x_clipped` was exactly zero, and every value at or + # above 1 came out as +inf. Keep the margin at least one float32 + # step wide. + margin = tf.maximum( + tf.cast(self.epsilon, x.dtype), + tf.cast(_FLOAT32_STEP, x.dtype), + ) + x_clipped = tf.clip_by_value(x, margin, 1.0 - margin) return tf.math.log(x_clipped / (1.0 - x_clipped)) def apply_min_max() -> tf.Tensor: - # Min-Max scaling to [0, 1] - if self.clip_values: - # Use predefined min/max values - min_val = self.min_value - max_val = self.max_value - else: - # Compute min/max from data - min_val = tf.reduce_min(x) - max_val = tf.reduce_max(x) + # Scale each feature's own range onto [min_value, max_value]. + # + # Those two used to be read as the *source* range instead -- + # `(x - min_value) / (max_value - min_value)` -- so with their + # defaults of 0 and 1 the transform returned its input untouched, + # and no setting could scale onto a range other than [0, 1]. + # `clip_values` chose between that and the data's own range, which + # is not what "whether to clip values to the specified range" says + # and left the output far outside the range it names. + # + # Per feature, like `robust-scale` and `quantile` below: a single + # min and max across the whole tensor would let one wide column + # flatten every other one. + data_min = tf.reduce_min(x, axis=0, keepdims=True) + data_max = tf.reduce_max(x, axis=0, keepdims=True) + + spread = data_max - data_min + spread = tf.where( + tf.abs(spread) < self.epsilon, + tf.ones_like(spread), + spread, + ) - # Avoid division by zero - denom = max_val - min_val - denom = tf.where(tf.abs(denom) < self.epsilon, tf.ones_like(denom), denom) + low = tf.cast(self.min_value, x.dtype) + high = tf.cast(self.max_value, x.dtype) + scaled = low + (x - data_min) / spread * (high - low) - return (x - min_val) / denom + if self.clip_values: + scaled = tf.clip_by_value(scaled, low, high) + return scaled def apply_robust_scale() -> tf.Tensor: # Robust scaling: centre on the per-feature median and divide by the diff --git a/kdp/layers/time_series/tsfresh_feature_layer.py b/kdp/layers/time_series/tsfresh_feature_layer.py index 9f02ea3..8ae9625 100644 --- a/kdp/layers/time_series/tsfresh_feature_layer.py +++ b/kdp/layers/time_series/tsfresh_feature_layer.py @@ -134,7 +134,28 @@ def build(self, input_shape) -> None: Args: input_shape: Shape of the input tensor. + + Raises: + ValueError: If there is only one step to summarize. Every statistic + this layer computes is degenerate over a window of one -- the + mean, the minimum and the maximum are all the value itself and + the standard deviation is zero -- and `window_size` is clamped + to the number of steps available, so a `TimeSeriesFeature` that + hands over one column per row got its own column back once per + requested statistic, plus a column of zeros. It looked like + four engineered features and carried nothing. """ + time_steps = tuple(input_shape)[1] if len(input_shape) > 1 else None + if time_steps is not None and time_steps < 2: + raise ValueError( + "TSFreshFeatureLayer needs at least two time steps on axis 1 " + f"and received {time_steps}. In a `TimeSeriesFeature` this means " + "combining `tsfresh_feature_config` with a config that widens " + "the feature first -- `lag_config`, `rolling_stats_config` or " + "`moving_average_config` -- so there is a window to summarize. " + "Used directly, pass `(batch, time_steps)` or " + "`(batch, time_steps, features)`.", + ) super().build(input_shape) def call(self, inputs, training=None) -> tf.Tensor: diff --git a/kdp/model_advisor.py b/kdp/model_advisor.py index f787070..4bbab37 100644 --- a/kdp/model_advisor.py +++ b/kdp/model_advisor.py @@ -810,6 +810,11 @@ def generate_code_snippet(self) -> str: code.append("") code.append("# Create preprocessing model with recommended configuration") code.append("model = PreprocessingModel(") + # Without this the snippet cannot be run: every configuration the + # advisor recommends has numeric, text or vocabulary-based features in + # it, and building one of those needs the data. Pasting the snippet + # raised "neither `features_stats` nor `path_data` was provided". + code.append(' path_data="your_data.csv", # the data these came from') code.append(" features_specs=features,") # Add global configuration diff --git a/kdp/processor.py b/kdp/processor.py index e89deb1..94763cc 100644 --- a/kdp/processor.py +++ b/kdp/processor.py @@ -17,10 +17,12 @@ from pathlib import Path import json import numpy as np +import pandas as pd from loguru import logger from kdp.layers.global_numerical_embedding_layer import GlobalNumericalEmbedding +from kdp.inference.base import InferenceFormatter from kdp.features import ( CategoricalFeature, FeatureSpaceConverter, @@ -63,15 +65,35 @@ def _to_tensor_mapping(data: dict) -> dict: converted = {} for key, value in data.items(): if isinstance(value, tf.Tensor) or tf.is_tensor(value): - converted[key] = value + converted[key] = _as_column(value) continue try: - converted[key] = tf.convert_to_tensor(value) + converted[key] = _as_column(tf.convert_to_tensor(value)) except (ValueError, TypeError, tf.errors.OpError): converted[key] = value return converted +def _as_column(tensor: tf.Tensor) -> tf.Tensor: + """Give a per-row value list the (rows, 1) shape every input is declared with. + + Every feature is a `keras.Input(shape=(1,))`, so a column of N values has to + arrive as (N, 1). A flat list -- `{"age": [35.0, 41.0]}`, and everything + `InferenceFormatter` produces -- converts to shape (N,), which `predict()` + rejected with "as_list() is not defined on an unknown TensorShape". The + values were never wrong, only the axis they were missing. + + Args: + tensor: A converted feature column. + + Returns: + The same values with a trailing axis when they arrived without one. + """ + if len(tensor.shape) == 1: + return tf.expand_dims(tensor, axis=-1) + return tensor + + class CallableDict(dict): """A dictionary that can be called like a function. @@ -313,6 +335,9 @@ def __init__( self.features_specs = features_specs or {} self.features_stats_path = features_stats_path or "features_stats.json" self.feature_crosses = feature_crosses or [] + # Filled in by `_add_pipeline_cross`: the names the crossed tensors + # are stored under in `processed_features`. + self._cross_feature_names: list[str] = [] self.output_mode = output_mode self.overwrite_stats = overwrite_stats self.use_caching = use_caching @@ -1300,8 +1325,14 @@ def _adapted_text_vectorizer( """ if not self.path_data: raise ValueError( - f"{feature_name}: output_mode='tf_idf' needs the data to compute " - "IDF weights from, so `path_data` is required.", + f"{feature_name}: this configuration " + f"(output_mode={kwargs.get('output_mode')!r}, " + f"max_tokens={kwargs.get('max_tokens')!r}, " + f"ngrams={kwargs.get('ngrams')!r}, " + f"standardize={kwargs.get('standardize')!r}, " + f"split={kwargs.get('split')!r}) needs its vocabulary built from " + f"the column itself -- the collected statistics cannot describe " + f"it -- so `path_data` is required.", ) vectorizer = keras.layers.TextVectorization(**kwargs) @@ -1319,8 +1350,53 @@ def _adapted_text_vectorizer( f"Adapted the text vectorizer for {feature_name} on the data; " f"vocabulary size {len(vectorizer.get_vocabulary())}", ) + + if kwargs.get("output_mode") == TextVectorizerOutputOptions.TF_IDF: + vectorizer = self._restatable_tf_idf_vectorizer(vectorizer, **kwargs) + return vectorizer + @staticmethod + def _restatable_tf_idf_vectorizer( + adapted: keras.layers.TextVectorization, + **kwargs: Any, + ) -> keras.layers.TextVectorization: + """Rebuild an adapted `tf_idf` vectorizer so that it can be reloaded. + + A `TextVectorization` in `tf_idf` mode that learned its state by + adapting cannot be read back from a saved model: Keras writes the IDF + weights as layer variables, and on load assigns them to a layer that has + no vocabulary yet and therefore no such variable, which surfaces as + "object of type 'bool' has no len()". The model saves without + complaint, so the loss shows up only when the model is next needed. This + happens with `TextVectorization` alone, with no KDP in the picture. + + Passing the vocabulary and the weights to the constructor puts both in + the layer's config, where loading reads them. The layer computes the + same numbers either way. + + Args: + adapted: The vectorizer that has already read the column. + **kwargs: The arguments it was constructed with. + + Returns: + An equivalent vectorizer holding its state in its config. + """ + vocabulary = [str(word) for word in adapted.get_vocabulary()] + specials = len(vocabulary) - len( + adapted.get_vocabulary(include_special_tokens=False), + ) + weights = np.asarray(adapted._lookup_layer.idf_weights_const.numpy()) + + arguments = dict(kwargs) + arguments.pop("vocabulary", None) + arguments.pop("idf_weights", None) + return keras.layers.TextVectorization( + vocabulary=vocabulary[specials:], + idf_weights=weights[specials:], + **arguments, + ) + @_monitor_performance def _add_pipeline_text(self, feature_name: str, input_layer, stats: dict) -> None: """Add a text preprocessing step to the pipeline. @@ -1368,13 +1444,62 @@ def _add_pipeline_text(self, feature_name: str, input_layer, stats: dict) -> Non ): _feature.kwargs["output_sequence_length"] = 35 + # `max_tokens` says how large a vocabulary to keep, and the + # statistics pass collects every distinct word it sees. Handing a + # larger vocabulary to a capped layer is refused by Keras -- + # "Attempted to set a vocabulary larger than the maximum vocab + # size" -- so asking for a smaller vocabulary than the data holds, + # which is the only reason to set the option, could not build. + # `int` reserves two slots (padding and out-of-vocabulary), the + # other modes one. Adapting on the column keeps the most frequent + # `max_tokens` words, which is what the cap means; the statistics + # carry no counts to pick them with. + _max_tokens = _feature.kwargs.get("max_tokens") + _reserved = ( + 2 + if _feature.kwargs.get("output_mode", TextVectorizerOutputOptions.INT) + == TextVectorizerOutputOptions.INT + else 1 + ) + _vocab_too_large = ( + _max_tokens is not None and len(_vocab) + _reserved > _max_tokens + ) + + # The statistics collect single words, standardized the way + # `TextVectorization` standardizes by default. Any option that + # changes what a token is puts the two out of step: `ngrams` asks + # for word pairs the statistics never recorded, and a custom + # `standardize` or `split` spells them differently. The vocabulary + # was handed over regardless, so every n-gram and every differently + # spelled token landed in the one out-of-vocabulary slot -- the + # widths and the counts all looked right. Read the column instead + # when that happens, and let the layer build the vocabulary it will + # actually use. + _tokenizes_differently = any( + _feature.kwargs.get(option) is not None + for option in ("ngrams", "standardize", "split") + ) + _needs_its_own_vocabulary = _vocab_too_large or _tokenizes_differently + if _needs_its_own_vocabulary: + logger.info( + f"{feature_name}: the statistics vocabulary cannot describe " + f"this vectorizer (max_tokens={_max_tokens}, " + f"ngrams={_feature.kwargs.get('ngrams')}, " + f"standardize={_feature.kwargs.get('standardize')}, " + f"split={_feature.kwargs.get('split')}); adapting it on the " + f"column instead.", + ) + # `tf_idf` weights every token by how rare it is across documents, # and Keras refuses a vocabulary in that mode without the matching # `idf_weights`, which KDP's statistics do not collect -- so the # documented, exported `TF_IDF` output mode could not build at all. # Letting the layer adapt on the column computes both the vocabulary # and the weights, from the same data the statistics came from. - if _feature.kwargs.get("output_mode") == TextVectorizerOutputOptions.TF_IDF: + if ( + _feature.kwargs.get("output_mode") == TextVectorizerOutputOptions.TF_IDF + or _needs_its_own_vocabulary + ): vectorizer = self._adapted_text_vectorizer( feature_name=feature_name, name=f"text_vactorizer_{feature_name}", @@ -1696,11 +1821,23 @@ def _add_pipeline_cross(self) -> None: 3. Converting output to float32 for compatibility 4. Adding the result to appropriate output collection based on output mode """ + self._cross_feature_names = [] for feature_a, feature_b, nr_bins in self.feature_crosses: preprocessor = FeaturePreprocessor(name=f"{feature_a}_x_{feature_b}") # checking inputs existance for feature A for _feature_name in [feature_a, feature_b]: + # A name that is not a declared feature used to surface as a + # bare `KeyError: 'name'` from the line below, with nothing to + # say where it came from. + if _feature_name not in self.features_specs: + raise ValueError( + f"feature_crosses asks to cross '{feature_a}' with " + f"'{feature_b}', but '{_feature_name}' is not in " + f"features_specs. A cross combines two declared " + f"features: add it to features_specs, or drop the " + f"cross.", + ) # getting feature object _feature = self.features_specs[_feature_name] _input = self.inputs.get(_feature_name) @@ -1709,6 +1846,23 @@ def _add_pipeline_cross(self) -> None: _col_dtype = _feature.dtype self._add_input_column(feature_name=_feature, dtype=_col_dtype) + # `HashedCrossing` hashes the raw values, so it only accepts + # integer or string columns. Crossing a float feature used to build + # a model that looked fine -- summary, output shape, everything -- + # and then raise on the first batch it was given. Refuse it here, + # where the configuration that caused it is still in view. + for _feature_name in (feature_a, feature_b): + _dtype = str(self.inputs[_feature_name].dtype) + if _dtype != "string" and not _dtype.startswith(("int", "uint")): + raise ValueError( + f"feature_crosses asks to cross '{feature_a}' with " + f"'{feature_b}', but '{_feature_name}' is a " + f"{_dtype} column and a hashed cross can only combine " + f"integer or string columns. Cross categorical " + f"features, or bucket the numeric one into a " + f"categorical feature first.", + ) + feature_name = f"{feature_a}_x_{feature_b}" preprocessor.add_processing_step( layer_class="HashedCrossing", @@ -1726,6 +1880,7 @@ def _add_pipeline_cross(self) -> None: # Process the feature self.processed_features[feature_name] = _output_pipeline + self._cross_feature_names.append(feature_name) @_monitor_performance def _score_features_against_each_other(self) -> None: @@ -1888,6 +2043,32 @@ def _combine_all_features( else: raise ValueError("No features available for concatenation") + def _concatenation_order(self) -> list[str]: + """The processed features to concatenate, in a stable order. + + Declared features come first, in the order `features_specs` lists them, + followed by the crosses in the order `feature_crosses` gives them. + Anything else is sorted by name so that it cannot move either. + + Returns: + The feature names to concatenate, without the selection weights. + """ + ordered = [ + name for name in self.features_specs if name in self.processed_features + ] + ordered += [ + name + for name in self._cross_feature_names + if name in self.processed_features + ] + placed = set(ordered) + ordered += sorted( + name + for name in self.processed_features + if name not in placed and not name.endswith("_weights") + ) + return ordered + def _group_features_by_type(self) -> tuple[list, list]: """Group processed features by type for concatenation. @@ -1909,10 +2090,30 @@ def _group_features_by_type(self) -> tuple[list, list]: passthrough_names_numeric = [] passthrough_names_string = [] - # Group processed features by type - for feature_name, feature in self.processed_features.items(): - # Skip feature weights - if feature_name.endswith("_weights"): + # Group processed features by type. + # + # In the order the features were declared, not the order they finished + # being built in. Features are processed in parallel batches, so + # `processed_features` is keyed by whichever worker returned first: + # building the same configuration twice put the columns of `concat_all` + # in a different order, five different layouts in six builds. A model + # trained on one build and served by another read every feature as + # another one, and nothing said so. The saved `.keras` file was never + # affected -- the order is baked into its graph -- so this only bit + # callers who rebuilt the preprocessor instead of reloading it. + for feature_name in self._concatenation_order(): + feature = self.processed_features[feature_name] + + # A cross is configured with `feature_crosses`, not by declaring a + # column, so it has no entry in `features_specs`. The lookup below + # therefore found nothing and skipped it: `_add_pipeline_cross` + # built every cross and concatenation then threw them all away, so + # in the default output mode the option did nothing at all. A + # hashed cross is a categorical index, so it joins that block. + if feature_name in self._cross_feature_names: + logger.debug(f"Adding cross {feature_name} to categorical features") + categorical_features.append(feature) + categorical_names.append(feature_name) continue # Add to appropriate list based on feature type @@ -2310,6 +2511,16 @@ def _apply_all_features_transformer(self) -> None: name=f"transformer_block_{block_idx}_{self.transfo_nr_heads}heads", )(self.concat_all) + # A transformer block adds a sequence axis to a 2-D input and hands it + # back, so this placement was the one configuration whose model came out + # as (rows, 1, width) instead of (rows, width) -- enough to break a Dense + # head bolted on after it. The categorical placement already flattened + # the same way, one method above. + self.concat_all = keras.layers.Reshape( + target_shape=(-1,), + name="reshape_transformer_output", + )(self.concat_all) + def _prepare_dict_mode_outputs(self) -> None: """Prepare outputs for dictionary mode.""" # Dictionary mode @@ -3256,6 +3467,19 @@ def predict(self, data, **kwargs) -> Any: # Validate time series inference data self._validate_time_series_inference_data(data) + # A DataFrame is the first thing the docstring offers, and it went + # straight to Keras, which tried to read every column as a float and + # raised "could not convert string to float" on the first categorical + # one. `InferenceFormatter` already takes a frame apart column by + # column, and it also decides each column's type from the values that + # are really there -- so a row with a gap in it survives instead of + # raising "Invalid dtype: object" on the mixed column it produces. + if isinstance(data, pd.DataFrame): + data = InferenceFormatter(self).prepare_inference_data( + data, + to_tensors=True, + ) + # Keras cannot consume NumPy string arrays, which every categorical, # text or date feature produces when the caller passes a plain dict. if isinstance(data, dict): diff --git a/kdp/stats.py b/kdp/stats.py index f7cd763..183827e 100644 --- a/kdp/stats.py +++ b/kdp/stats.py @@ -22,52 +22,233 @@ class WelfordAccumulator: - """Accumulator for computing the mean and variance of a sequence of numbers - using the Welford algorithm (streaming data). + """Streaming mean and variance for a sequence of numbers. + + Accumulates in float64 and pools each batch with the Chan-Golub-LeVeque + update. Both matter, and float32 was doing real damage: the deviations + `x - mean` were computed at the magnitude of the values themselves, so a + column of large numbers lost every significant digit to cancellation. A + column around 1e8 with a spread of 1e3 came out with a variance of + *minus* 1.6e6 against a true 1.0e6, and one with a spread of 1e-4 was + wrong by four orders of magnitude. + + Nothing raised. `Normalization` divides by the square root of that + variance, so the column arrived at the model as a constant -- every ID, + epoch timestamp, amount in cents or sensor reading with a large offset + silently flattened, its signal gone. + + Pooling each batch from its own mean keeps the subtraction at the scale of + the spread rather than the values, and float64 leaves ten orders of + magnitude of headroom on top. Together they hold the relative error near + 1e-13 on the cases that used to break. """ def __init__(self): """Initializes the accumulators for the Welford algorithm.""" self.n = tf.Variable( 0.0, - dtype=tf.float32, + dtype=tf.float64, trainable=False, ) self.mean = tf.Variable( 0.0, - dtype=tf.float32, + dtype=tf.float64, trainable=False, ) self.M2 = tf.Variable( 0.0, - dtype=tf.float32, + dtype=tf.float64, + trainable=False, + ) + # Third and fourth central moments, pooled the same way as M2, and the + # running extremes. The advisor reads skewness, kurtosis, min and max; + # none of them were ever collected, so every column arrived at it with + # the neutral defaults -- skew 0, kurtosis 3 -- and was reported as + # "Normal distribution detected" whatever shape it had, while the + # rescaling factor derived from min and max always came out as 1. + self.M3 = tf.Variable( + 0.0, + dtype=tf.float64, + trainable=False, + ) + self.M4 = tf.Variable( + 0.0, + dtype=tf.float64, + trainable=False, + ) + self.minimum = tf.Variable( + float("inf"), + dtype=tf.float64, + trainable=False, + ) + self.maximum = tf.Variable( + float("-inf"), + dtype=tf.float64, trainable=False, ) self.var = tf.Variable( 0.0, - dtype=tf.float32, + dtype=tf.float64, trainable=False, ) @tf.function def update(self, values: tf.Tensor) -> None: - """Updates the accumulators with new values using the Welford algorithm. + """Pool a batch of values into the running mean and variance. Args: values: The new values to add to the accumulators. """ - values = tf.cast(values, tf.float32) - n = self.n + tf.cast(tf.size(values), tf.float32) - delta = values - self.mean - self.mean.assign(self.mean + tf.reduce_sum(delta / n)) - self.M2.assign(self.M2 + tf.reduce_sum(delta * (values - self.mean))) - self.n.assign(n) + values = tf.cast(tf.reshape(values, [-1]), tf.float64) + batch_count = tf.cast(tf.size(values), tf.float64) + + # An empty batch has to leave everything untouched, and both divisors + # below can be zero when it does. Flooring them at one keeps the + # arithmetic finite; every numerator carries `batch_count`, so an empty + # batch contributes nothing either way. + safe_batch_count = tf.maximum(batch_count, 1.0) + batch_mean = tf.reduce_sum(values) / safe_batch_count + centered = values - batch_mean + batch_m2 = tf.reduce_sum(tf.square(centered)) + batch_m3 = tf.reduce_sum(centered * tf.square(centered)) + batch_m4 = tf.reduce_sum(tf.square(tf.square(centered))) + + # The identity element rather than a branch: an empty batch reduces to + # the infinity that leaves the running extreme alone. + infinity = tf.constant([float("inf")], dtype=tf.float64) + batch_min = tf.reduce_min(tf.concat([values, infinity], axis=0)) + batch_max = tf.reduce_max(tf.concat([values, -infinity], axis=0)) + + self._pool( + count=batch_count, + mean=batch_mean, + m2=batch_m2, + m3=batch_m3, + m4=batch_m4, + minimum=batch_min, + maximum=batch_max, + ) + + def _pool( + self, + count: tf.Tensor, + mean: tf.Tensor, + m2: tf.Tensor, + m3: tf.Tensor, + m4: tf.Tensor, + minimum: tf.Tensor, + maximum: tf.Tensor, + ) -> None: + """Fold a summarized group of values into the running accumulators. + + The pooled forms of the Chan-Golub-LeVeque update, carried up to the + fourth moment. Both `update` and `merge` reduce to the same arithmetic. + + Args: + count: How many values the group holds. + mean: Their mean. + m2: The sum of their squared deviations from that mean. + m3: The sum of their cubed deviations. + m4: The sum of their fourth-power deviations. + minimum: The smallest of them, or +inf for an empty group. + maximum: The largest of them, or -inf for an empty group. + """ + total = self.n + count + safe_total = tf.maximum(total, 1.0) + delta = mean - self.mean + held = self.n + + self.M4.assign( + self.M4 + + m4 + + tf.square(tf.square(delta)) + * held + * count + * (held * held - held * count + count * count) + / (safe_total * tf.square(safe_total)) + + 6.0 + * delta + * delta + * (held * held * m2 + count * count * self.M2) + / tf.square(safe_total) + + 4.0 * delta * (held * m3 - count * self.M3) / safe_total, + ) + self.M3.assign( + self.M3 + + m3 + + delta + * tf.square(delta) + * held + * count + * (held - count) + / tf.square(safe_total) + + 3.0 * delta * (held * m2 - count * self.M2) / safe_total, + ) + self.M2.assign(self.M2 + m2 + delta * delta * held * count / safe_total) + self.mean.assign(self.mean + delta * count / safe_total) + self.minimum.assign(tf.minimum(self.minimum, minimum)) + self.maximum.assign(tf.maximum(self.maximum, maximum)) + self.n.assign(total) + + def merge(self, other: "WelfordAccumulator") -> None: + """Pool another accumulator's values into this one. + + Args: + other: The accumulator whose values are being folded in. + """ + self._pool( + count=tf.cast(other.n, tf.float64), + mean=tf.cast(other.mean, tf.float64), + m2=tf.cast(other.M2, tf.float64), + m3=tf.cast(other.M3, tf.float64), + m4=tf.cast(other.M4, tf.float64), + minimum=tf.cast(other.minimum, tf.float64), + maximum=tf.cast(other.maximum, tf.float64), + ) @property def variance(self) -> float: """Returns the variance of the accumulated values.""" return self.M2 / (self.n - 1) if self.n > 1 else self.var + @property + def skewness(self) -> float: + """How lopsided the values are: 0 when symmetric, positive to the right. + + Returns: + The population (Fisher-Pearson) skewness, or 0.0 when there are too + few values, or no spread, to have one. + """ + if self.n < 3 or self.M2 <= 0: + return tf.constant(0.0, dtype=tf.float64) + return tf.sqrt(self.n) * self.M3 / tf.pow(self.M2, 1.5) + + @property + def kurtosis(self) -> float: + """How heavy the tails are, on the scale where a normal curve is 3. + + Returns: + The population kurtosis, or 3.0 when there are too few values, or + no spread, to have one. + """ + if self.n < 4 or self.M2 <= 0: + return tf.constant(3.0, dtype=tf.float64) + return self.n * self.M4 / tf.square(self.M2) + + @property + def smallest(self) -> float: + """The smallest value seen, or 0.0 before any has been.""" + if self.n < 1: + return tf.constant(0.0, dtype=tf.float64) + return self.minimum.value() + + @property + def largest(self) -> float: + """The largest value seen, or 0.0 before any has been.""" + if self.n < 1: + return tf.constant(0.0, dtype=tf.float64) + return self.maximum.value() + @property def count(self) -> int: """Returns the number of accumulated values.""" @@ -120,6 +301,12 @@ def get_unique_values(self) -> list: return tf.unique(all_values)[0].numpy().tolist() +# The punctuation `TextVectorization` removes under its default +# `standardize="lower_and_strip_punctuation"`, copied from that layer so the +# vocabulary collected here is spelled the way the layer will look it up. +_KERAS_PUNCTUATION = r'[!"#$%&()\*\+,-\./:;<=>?@\[\\\]^_`{|}~\']' + + class TextAccumulator: def __init__(self) -> None: """Initializes the accumulator for text values, where each entry is a list of words separated by spaces. @@ -151,10 +338,18 @@ def update(self, new_texts: tf.Tensor) -> None: f"Unsupported data type for text features: {new_texts.dtype}", ) - # Split each string into words and flatten the list - new_texts = tf.strings.regex_replace(new_texts, r"\s+", " ") + # Tokenize exactly the way `TextVectorization` will, because this + # vocabulary is handed to that layer verbatim. Its default + # standardization lowercases and then strips punctuation before + # splitting on whitespace; this accumulator lowercased but kept the + # punctuation, so a column of ordinary prose produced a vocabulary of + # "product," and "it!" while the layer looked up "product" and "it". + # The width was right and the counts were right; the words simply were + # not there, and a large share of every sentence fell into the + # out-of-vocabulary slot. + new_texts = tf.strings.lower(new_texts) + new_texts = tf.strings.regex_replace(new_texts, _KERAS_PUNCTUATION, "") split_words = tf.strings.split(new_texts).flat_values - split_words = tf.strings.lower(split_words) # Concatenate new words with existing words and update unique words updated_words = tf.unique(tf.concat([self.words, split_words], axis=0))[0] @@ -248,6 +443,53 @@ def variance(self) -> dict: } +def _warn_if_float32_flattens_the_column( + feature: str, + mean: float, + variance: float, +) -> None: + """Say so when a column's spread is too fine for float32 to hold. + + Everything downstream -- the CSV reader, the model's inputs, the + normalization layer -- is float32, which carries about seven significant + digits. A column whose values sit far from zero but vary only slightly + loses that variation on the way in, before any statistic is computed and + before any layer sees it. Unix timestamps in seconds are the usual case: + values near 1.6e9 are 128 apart in float32, so a column spread over a + minute arrives as two or three distinct numbers. Normalization then works + perfectly on what is left, and the feature reaching the model is a + constant. + + Nothing raises, because the numbers are the ones the file actually + contains. This only makes the loss visible. + + Args: + feature: The column's name, for the message. + mean: The column's mean. + variance: The column's variance. + """ + mean = float(mean) + variance = float(variance) + if not math.isfinite(mean) or not math.isfinite(variance) or variance <= 0.0: + return + + # The gap between neighbouring float32 values at this magnitude. + resolution = float(np.spacing(np.float32(abs(mean)))) + spread = math.sqrt(variance) + if resolution <= 0.0 or spread >= 16.0 * resolution: + return + + levels = max(int(6.0 * spread / resolution), 1) + logger.warning( + f"Feature '{feature}' has values around {mean:.6g} varying by only " + f"{spread:.6g}, which float32 cannot hold: neighbouring values at that " + f"magnitude are {resolution:.6g} apart, so the column arrives as about " + f"{levels} distinct value(s) and carries almost no information. " + f"Subtract a reference point before training (for a Unix timestamp, " + f"the start of your data), or declare the column as a DATE feature.", + ) + + class DatasetStatistics: def __init__( self, @@ -349,15 +591,32 @@ def _get_csv_file_pattern(path) -> str: ) file_path = Path(path) - # Check if the path is a directory + # A path that names a file means that file. It used to be replaced with + # `/*.csv`, so `path_data="data/train.csv"` computed statistics + # over every CSV sitting beside it -- `test.csv` included. That is the + # usage every example in the documentation shows, and the leak was + # silent: the numbers came back, just drawn from the wrong rows. + if file_path.is_file(): + return str(file_path) + if file_path.is_dir(): + return str(file_path / "*.csv") + + # Neither, so it is either a glob the caller wrote themselves + # ("data/*.csv", "shard-*.csv") or a path that does not exist. A glob is + # passed through; anything else is reported here rather than inside + # `tf.data`, which answers a missing file with an empty dataset and + # statistics quietly computed from nothing. + if any(character in str(file_path) for character in "*?["): + return str(file_path) if file_path.suffix: - # Get the parent directory if the path is a file - base_path = file_path.parent - csv_pattern = base_path / "*.csv" - else: - csv_pattern = file_path / "*.csv" - - return str(csv_pattern) + raise FileNotFoundError( + f"`path_data` points at {str(file_path)!r}, which does not " + "exist. Pass a CSV file, a directory of CSV files, or a glob.", + ) + raise FileNotFoundError( + f"`path_data` points at {str(file_path)!r}, which is not a file or " + "a directory. Pass a CSV file, a directory of CSV files, or a glob.", + ) def _read_data_into_dataset(self) -> tf.data.Dataset: """Reading CSV files from the provided path into a tf.data.Dataset.""" @@ -459,6 +718,20 @@ def _process_time_series_data(self) -> dict: ) continue + # A calendar feature's column holds dates, not numbers: everything + # it produces is derived from the string by `CalendarFeatureLayer`, + # and there is no mean or variance to take. Feeding it to the + # accumulator anyway killed the statistics pass with "Cast string to + # double is not supported" from inside a `tf.function`, so the + # documented way of declaring calendar features could not be built + # at all. + if feature.dtype == tf.string: + logger.debug( + f"'{feature_name}' is a calendar feature; its column holds " + f"dates, so there are no numeric statistics to collect.", + ) + continue + # Prepare for grouped processing if grouping is specified if feature.group_by and feature.group_by in list( dataset.element_spec.keys(), @@ -517,28 +790,25 @@ def _process_time_series_data(self) -> dict: accumulator.update(sorted_tensor) group_accumulators[group_key] = accumulator - # Combine statistics across groups + # Combine statistics across groups. Each group is pooled in + # whole. Replacing a group with `count` copies of its own mean + # -- what this did before -- throws away everything the group + # varied by, leaving only the variance *between* the group + # means: two groups of spread 25 whose means happened to be + # close reported a variance of 0.73 against a true 643. if group_accumulators: - # Create overall accumulator to combine statistics combined_accumulator = WelfordAccumulator() - - # Combine all group means weighted by count - all_values = [] - for _, acc in group_accumulators.items(): - mean_tensor = ( - tf.ones(shape=(int(acc.count.numpy()),), dtype=tf.float32) - * acc.mean.numpy() - ) - all_values.append(mean_tensor) - - if all_values: - combined_tensor = tf.concat(all_values, axis=0) - combined_accumulator.update(combined_tensor) + for accumulator in group_accumulators.values(): + combined_accumulator.merge(accumulator) # Calculate and store overall statistics stats = { "mean": float(combined_accumulator.mean.numpy()), "var": float(combined_accumulator.variance.numpy()), + "min": float(combined_accumulator.smallest.numpy()), + "max": float(combined_accumulator.largest.numpy()), + "skewness": float(combined_accumulator.skewness.numpy()), + "kurtosis": float(combined_accumulator.kurtosis.numpy()), "count": int( sum( acc.count.numpy() for acc in group_accumulators.values() @@ -613,6 +883,10 @@ def _process_time_series_data(self) -> dict: stats = { "mean": float(accumulator.mean.numpy()), "var": float(accumulator.variance.numpy()), + "min": float(accumulator.smallest.numpy()), + "max": float(accumulator.largest.numpy()), + "skewness": float(accumulator.skewness.numpy()), + "kurtosis": float(accumulator.kurtosis.numpy()), "count": int(accumulator.count.numpy()), "dtype": feature.dtype.name if hasattr(feature.dtype, "name") @@ -705,10 +979,22 @@ def compute_feature_stats(feature: str) -> tuple[str, dict]: - date: mean and variance for each date component """ if feature_type == "numeric": + accumulator = self.numeric_stats[feature] + mean = accumulator.mean.numpy() + variance = accumulator.variance.numpy() + _warn_if_float32_flattens_the_column(feature, mean, variance) return feature, { - "mean": self.numeric_stats[feature].mean.numpy(), - "count": self.numeric_stats[feature].count.numpy(), - "var": self.numeric_stats[feature].variance.numpy(), + "mean": mean, + "count": accumulator.count.numpy(), + "var": variance, + # The advisor reads these four to tell one distribution + # from another and to size a rescaling factor. None of them + # were collected, so it read the neutral defaults and + # called every column normal. + "min": float(accumulator.smallest.numpy()), + "max": float(accumulator.largest.numpy()), + "skewness": float(accumulator.skewness.numpy()), + "kurtosis": float(accumulator.kurtosis.numpy()), "dtype": self.features_specs[feature].dtype, } elif feature_type == "categorical": diff --git a/test/layers/test_distribution_transform_math.py b/test/layers/test_distribution_transform_math.py index f901429..0644985 100644 --- a/test/layers/test_distribution_transform_math.py +++ b/test/layers/test_distribution_transform_math.py @@ -121,10 +121,27 @@ def test_output_is_bounded(self): class TestMinMax(unittest.TestCase): - """min-max maps data into [0, 1].""" + """min-max maps each feature's own range onto [min_value, max_value]. + + `min_value` and `max_value` were read as the range the data was assumed to + be *in*, not the range to scale it *onto*: the transform computed + `(x - min_value) / (max_value - min_value)`, which with their defaults of 0 + and 1 returns the input untouched. Min-max scaling did nothing at all + unless `clip_values` was turned off, and no setting could target a range + other than [0, 1]. The only test here covered that one working branch. + """ + + def test_the_default_settings_scale_into_the_unit_interval(self): + """The documented behaviour, and the case that used to be a no-op.""" + data = _multi_scale_data(seed=5) + actual = np.asarray( + DistributionTransformLayer(transform_type="min-max")(tf.constant(data)), + ) + self.assertFalse(np.allclose(actual, data)) + self.assertAlmostEqual(float(actual.min()), 0.0, places=5) + self.assertAlmostEqual(float(actual.max()), 1.0, places=5) - def test_scales_into_unit_interval(self): - """With clipping disabled, the observed range maps onto [0, 1].""" + def test_with_clipping_disabled_too(self): data = _multi_scale_data(seed=5) actual = np.asarray( DistributionTransformLayer(transform_type="min-max", clip_values=False)( @@ -134,6 +151,53 @@ def test_scales_into_unit_interval(self): self.assertAlmostEqual(float(actual.min()), 0.0, places=5) self.assertAlmostEqual(float(actual.max()), 1.0, places=5) + def test_the_target_range_is_the_one_asked_for(self): + """[-1, 1] and [0, 10] were both unreachable.""" + data = _multi_scale_data(seed=6) + for low, high in ((-1.0, 1.0), (0.0, 10.0), (5.0, 6.0)): + with self.subTest(target=(low, high)): + actual = np.asarray( + DistributionTransformLayer( + transform_type="min-max", + min_value=low, + max_value=high, + )(tf.constant(data)), + ) + self.assertAlmostEqual(float(actual.min()), low, places=4) + self.assertAlmostEqual(float(actual.max()), high, places=4) + + def test_each_feature_is_scaled_on_its_own_range(self): + """Three columns centred at -3, 50 and 1000 each span the whole range. + + A single min and max across the tensor would leave the two narrow + columns squeezed into a sliver of it. + """ + data = _multi_scale_data(seed=7) + actual = np.asarray( + DistributionTransformLayer(transform_type="min-max")(tf.constant(data)), + ) + for column in range(data.shape[1]): + self.assertAlmostEqual(float(actual[:, column].min()), 0.0, places=4) + self.assertAlmostEqual(float(actual[:, column].max()), 1.0, places=4) + + def test_the_order_of_the_values_is_kept(self): + data = _multi_scale_data(seed=8) + actual = np.asarray( + DistributionTransformLayer(transform_type="min-max")(tf.constant(data)), + ) + for column in range(data.shape[1]): + np.testing.assert_array_equal( + np.argsort(actual[:, column]), + np.argsort(data[:, column]), + ) + + def test_a_constant_feature_does_not_divide_by_zero(self): + constant = np.full((40, 2), 7.0, dtype="float32") + actual = np.asarray( + DistributionTransformLayer(transform_type="min-max")(tf.constant(constant)), + ) + self.assertTrue(np.isfinite(actual).all()) + class TestGraphModeCompatibility(unittest.TestCase): """Every transform must build inside a functional model.""" diff --git a/test/layers/test_every_transform_matches_its_name.py b/test/layers/test_every_transform_matches_its_name.py new file mode 100644 index 0000000..e0be3ec --- /dev/null +++ b/test/layers/test_every_transform_matches_its_name.py @@ -0,0 +1,176 @@ +"""Each named transform has to compute the function it is named after. + +The other tests here check properties -- bounded output, per-feature scaling, +order preserved. A transform can satisfy every one of those and still not be +the function on the label: `min-max` returned its input untouched and passed a +bounded-output test, because the input happened to be bounded. + +So each one is compared against its definition, written out in numpy. +""" + +import unittest + +import numpy as np +import pytest +import tensorflow as tf + +from kdp.layers.distribution_transform_layer import DistributionTransformLayer + +EPSILON = 1e-10 + + +def _positive(seed=0, rows=400): + return ( + np.abs(np.random.default_rng(seed).gamma(2.0, 2.0, (rows, 1))) + 0.5 + ).astype( + "float32", + ) + + +def _mixed(seed=1, rows=400): + return np.random.default_rng(seed).normal(0, 3, (rows, 1)).astype("float32") + + +def _unit(seed=2, rows=400): + return np.random.default_rng(seed).uniform(0.05, 0.95, (rows, 1)).astype("float32") + + +def _yeo_johnson(x, lam): + out = np.empty_like(x, dtype=np.float64) + positive = x >= 0 + if lam == 0: + out[positive] = np.log1p(x[positive]) + else: + out[positive] = ((x[positive] + 1) ** lam - 1) / lam + negative = ~positive + if lam == 2: + out[negative] = -np.log1p(-x[negative]) + else: + out[negative] = -(((-x[negative] + 1) ** (2 - lam) - 1) / (2 - lam)) + return out + + +@pytest.mark.layers +class TestEveryTransformMatchesItsName(unittest.TestCase): + """Every transform against the function it claims to be.""" + + def _apply(self, kind, data, **kwargs): + layer = DistributionTransformLayer(transform_type=kind, **kwargs) + return np.asarray(layer(tf.constant(data)), dtype=np.float64) + + def _assert_matches(self, kind, actual, expected, tolerance=1e-3): + actual = np.asarray(actual, dtype=np.float64).ravel() + expected = np.asarray(expected, dtype=np.float64).ravel() + self.assertEqual(actual.shape, expected.shape, kind) + relative = np.max(np.abs(actual - expected) / (np.abs(expected) + 1e-6)) + self.assertLess( + relative, tolerance, f"{kind}: max relative error {relative:.2e}" + ) + + def test_none_is_the_identity(self): + data = _mixed() + self._assert_matches("none", self._apply("none", data), data) + + def test_log(self): + data = _positive() + self._assert_matches("log", self._apply("log", data), np.log(data + EPSILON)) + + def test_sqrt(self): + data = _positive() + self._assert_matches("sqrt", self._apply("sqrt", data), np.sqrt(data)) + + def test_arcsinh(self): + data = _mixed() + self._assert_matches("arcsinh", self._apply("arcsinh", data), np.arcsinh(data)) + + def test_cube_root(self): + data = _mixed() + self._assert_matches( + "cube-root", + self._apply("cube-root", data), + np.sign(data) * np.abs(data) ** (1 / 3), + ) + + def test_logit(self): + data = _unit() + self._assert_matches( + "logit", + self._apply("logit", data), + np.log(data / (1 - data)), + ) + + def test_box_cox(self): + data = _positive() + self._assert_matches( + "box-cox (lambda 0)", + self._apply("box-cox", data, lambda_param=0.0), + np.log(data + EPSILON), + ) + for lam in (0.5, 1.0, 2.0): + self._assert_matches( + f"box-cox (lambda {lam})", + self._apply("box-cox", data, lambda_param=lam), + (data**lam - 1) / lam, + ) + + def test_yeo_johnson(self): + data = _mixed() + for lam in (0.0, 0.5, 1.0): + self._assert_matches( + f"yeo-johnson (lambda {lam})", + self._apply("yeo-johnson", data, lambda_param=lam), + _yeo_johnson(data, lam), + ) + + def test_robust_scale(self): + data = _mixed() + iqr = np.percentile(data, 75) - np.percentile(data, 25) + self._assert_matches( + "robust-scale", + self._apply("robust-scale", data), + (data - np.median(data)) / iqr, + tolerance=1e-2, + ) + + def test_min_max(self): + """The one this file was written for: it returned its input.""" + data = _mixed() + low, high = -1.0, 1.0 + scaled = (data - data.min()) / (data.max() - data.min()) + self._assert_matches( + "min-max", + self._apply("min-max", data, min_value=low, max_value=high), + low + scaled * (high - low), + tolerance=1e-2, + ) + + def test_quantile_maps_ranks_onto_the_documented_interval(self): + """Ranks to [-1, 1], which is what the implementation says it does.""" + data = _mixed() + actual = self._apply("quantile", data) + ranks = np.argsort(np.argsort(data.ravel())) + expected = 2.0 * ((ranks + 0.5) / data.size) - 1.0 + self._assert_matches("quantile", actual, expected, tolerance=1e-2) + + def test_every_named_transform_is_covered_here(self): + """A transform added later must be measured too, not just listed.""" + layer = DistributionTransformLayer() + named = set(layer._valid_transforms) - {"auto"} + covered = { + "none", + "log", + "sqrt", + "box-cox", + "yeo-johnson", + "arcsinh", + "cube-root", + "logit", + "quantile", + "robust-scale", + "min-max", + } + self.assertEqual( + named - covered, + set(), + "these transforms have no test against their definition", + ) diff --git a/test/test_calendar_features_can_be_built.py b/test/test_calendar_features_can_be_built.py new file mode 100644 index 0000000..c2fd701 --- /dev/null +++ b/test/test_calendar_features_can_be_built.py @@ -0,0 +1,137 @@ +"""A calendar time series feature has to survive the statistics pass. + +The documented way to ask for calendar features is a `TimeSeriesFeature` whose +`calendar_feature_config` names the components to extract. Its column holds +dates, and everything it produces is derived from the string by +`CalendarFeatureLayer` -- there is no mean or variance to take. The statistics +pass fed it to the numeric accumulator anyway and died inside a `tf.function` +with "Cast string to double is not supported", so that whole documented +configuration could not be built at all. + +The layer itself was covered; the path from `PreprocessingModel` to it was not. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel +from kdp.features import TimeSeriesFeature +from kdp.processor import OutputModeOptions + +ROWS = 70 +COMPONENTS = [ + "year", + "month", + "day", + "day_of_week", + "day_of_year", + "week_of_year", + "is_weekend", + "is_month_start", + "is_month_end", +] + + +def _frame() -> pd.DataFrame: + # Nearly four years, so every component below actually varies. + stamps = pd.date_range("2021-01-01", periods=ROWS, freq="20D") + return pd.DataFrame( + { + "when": stamps.strftime("%Y-%m-%d"), + "sales": np.arange(ROWS, dtype=float), + }, + ) + + +@pytest.mark.time_series +class TestCalendarFeaturesCanBeBuilt(unittest.TestCase): + """From `features_specs` all the way to a tensor.""" + + def setUp(self) -> None: + self.directory = Path(tempfile.mkdtemp()) + self.frame = _frame() + self.data = self.directory / "data.csv" + self.frame.to_csv(self.data, index=False) + + def _run(self, **config) -> tuple[np.ndarray, int]: + feature = TimeSeriesFeature(name="when", calendar_feature_config=config) + preprocessor = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / f"stats_{len(config)}.json"), + features_specs={"when": feature, "sales": FeatureType.FLOAT_NORMALIZED}, + overwrite_stats=True, + output_mode=OutputModeOptions.DICT, + ) + preprocessor.build_preprocessor() + produced = np.asarray( + preprocessor.model( + { + "when": tf.constant(self.frame["when"].values.reshape(-1, 1)), + "sales": tf.constant( + self.frame["sales"].values.reshape(-1, 1), + dtype=tf.float32, + ), + }, + )["when"], + ) + return produced, feature.get_output_dim() + + def test_the_documented_configuration_builds_and_runs(self) -> None: + produced, declared = self._run(features=COMPONENTS) + self.assertEqual(produced.shape, (ROWS, len(COMPONENTS))) + self.assertEqual(produced.shape[1], declared) + self.assertTrue(np.isfinite(produced).all()) + + def test_no_component_comes_back_constant(self) -> None: + produced, _ = self._run(features=COMPONENTS) + constant = [ + name + for index, name in enumerate(COMPONENTS) + if produced[:, index].std() < 1e-12 + ] + self.assertEqual(constant, [], f"constant calendar columns: {constant}") + + def test_the_raw_components_are_the_ones_pandas_reads(self) -> None: + produced, _ = self._run(features=COMPONENTS, normalize=False) + stamps = pd.to_datetime(self.frame["when"]) + expected = { + "year": stamps.dt.year, + "month": stamps.dt.month, + "day": stamps.dt.day, + "day_of_week": stamps.dt.dayofweek, + "day_of_year": stamps.dt.dayofyear, + "week_of_year": stamps.dt.isocalendar().week.astype(int), + "is_weekend": stamps.dt.dayofweek.isin([5, 6]).astype(int), + "is_month_start": stamps.dt.is_month_start.astype(int), + "is_month_end": stamps.dt.is_month_end.astype(int), + } + for index, name in enumerate(COMPONENTS): + with self.subTest(component=name): + np.testing.assert_allclose( + produced[:, index], + expected[name].to_numpy().astype(float), + atol=1e-4, + ) + + def test_normalize_puts_them_in_the_unit_interval(self) -> None: + produced, _ = self._run(features=COMPONENTS, normalize=True) + self.assertGreaterEqual(produced.min(), 0.0) + self.assertLessEqual(produced.max(), 1.0) + + def test_the_cyclical_components_can_be_asked_for(self) -> None: + produced, declared = self._run( + features=["month_sin", "month_cos", "day_of_week_sin", "day_of_week_cos"], + ) + self.assertEqual(produced.shape, (ROWS, 4)) + self.assertEqual(produced.shape[1], declared) + self.assertTrue(np.isfinite(produced).all()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_dates_read_the_formats_they_accept.py b/test/test_dates_read_the_formats_they_accept.py new file mode 100644 index 0000000..f3842ea --- /dev/null +++ b/test/test_dates_read_the_formats_they_accept.py @@ -0,0 +1,140 @@ +"""A date column has to be read, or refused where the caller can see why. + +The parser reads year, then month, then day, with `-` or `/` between them. It +never read the format string it was given, so a day-first or month-first format +was accepted at construction, ignored, and met again at the first batch as an +assertion buried inside a TensorFlow graph error -- far from the line that +caused it. + +A time after the date is the other half: timestamps are what a great many date +columns actually hold, and "2021-06-15 13:45:00" had no way through this layer +at all. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel +from kdp.features import DateFeature +from kdp.layers.date_parsing_layer import DateParsingLayer +from kdp.processor import OutputModeOptions + +READABLE = ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S") +UNREADABLE = ("%d-%m-%Y", "%m/%d/%Y", "%d/%m/%Y", "nonsense") + + +@pytest.mark.unit +class TestDateFormatsAreCheckedWhereTheyAreWritten(unittest.TestCase): + """The format is judged at construction, not at the first batch.""" + + def test_readable_formats_are_accepted(self) -> None: + for date_format in READABLE: + with self.subTest(date_format=date_format): + DateFeature( + name="d", + feature_type=FeatureType.DATE, + format=date_format, + ) + + def test_unreadable_formats_are_refused_by_name(self) -> None: + for date_format in UNREADABLE: + with self.subTest(date_format=date_format): + with self.assertRaises(ValueError) as raised: + DateFeature( + name="signed_on", + feature_type=FeatureType.DATE, + format=date_format, + ) + message = str(raised.exception) + self.assertIn("signed_on", message) + self.assertIn(date_format, message) + + def test_date_format_is_the_same_option_as_format(self) -> None: + """Both spellings appear in the wild; only one was ever read.""" + feature = DateFeature( + name="d", + feature_type=FeatureType.DATE, + date_format="%Y/%m/%d", + ) + self.assertEqual(feature.kwargs.get("format"), "%Y/%m/%d") + + with self.assertRaises(ValueError): + DateFeature( + name="d", + feature_type=FeatureType.DATE, + date_format="%d/%m/%Y", + ) + + +@pytest.mark.layers +class TestTheParserReadsTimestamps(unittest.TestCase): + """A time after the date is dropped, not rejected.""" + + def test_a_date_with_a_time_parses_to_the_same_components(self) -> None: + layer = DateParsingLayer() + plain = np.asarray(layer(tf.constant([["2021-06-15"]]))) + spaced = np.asarray(layer(tf.constant([["2021-06-15 13:45:00"]]))) + iso = np.asarray(layer(tf.constant([["2021-06-15T13:45:00"]]))) + slashed = np.asarray(layer(tf.constant([["2021/06/15 13:45:00"]]))) + + np.testing.assert_array_equal(plain, spaced) + np.testing.assert_array_equal(plain, iso) + np.testing.assert_array_equal(plain, slashed) + # year, month, day, day-of-week + np.testing.assert_array_equal(plain[0][:3], [2021, 6, 15]) + + def test_a_column_of_timestamps_goes_through_a_model(self) -> None: + directory = Path(tempfile.mkdtemp()) + stamps = pd.date_range("2020-01-01 06:00:00", periods=60, freq="37h") + frame = pd.DataFrame( + { + "seen_at": stamps.strftime("%Y-%m-%d %H:%M:%S"), + "n": np.arange(60.0), + }, + ) + data = directory / "data.csv" + frame.to_csv(data, index=False) + + preprocessor = PreprocessingModel( + path_data=str(data), + features_stats_path=str(directory / "stats.json"), + features_specs={ + "seen_at": DateFeature( + name="seen_at", + feature_type=FeatureType.DATE, + format="%Y-%m-%d %H:%M:%S", + ), + "n": FeatureType.FLOAT_NORMALIZED, + }, + overwrite_stats=True, + output_mode=OutputModeOptions.DICT, + ) + preprocessor.build_preprocessor() + result = np.asarray( + preprocessor.model( + { + "seen_at": tf.constant(frame["seen_at"].values.reshape(-1, 1)), + "n": tf.constant( + frame["n"].values.reshape(-1, 1), + dtype=tf.float32, + ), + }, + )["seen_at"], + ) + self.assertEqual(result.shape, (60, 8)) + self.assertTrue(np.isfinite(result).all()) + self.assertGreater( + len(np.unique(result, axis=0)), + 1, + "every timestamp encoded to the same row", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_every_configuration_reloads.py b/test/test_every_configuration_reloads.py new file mode 100644 index 0000000..2f32fb8 --- /dev/null +++ b/test/test_every_configuration_reloads.py @@ -0,0 +1,298 @@ +"""A saved preprocessor has to come back with the numbers it went in with. + +Serialization is where this library has lost data silently: the model saves +without complaint, and the loss shows up only when someone needs the model +back. `output_mode="tf_idf"` was the last case -- Keras writes a `tf_idf` +vectorizer's IDF weights as layer variables and, on load, assigns them to a +layer that has no vocabulary yet and therefore no such variable, which surfaces +as `object of type 'bool' has no len()`. It happens with `TextVectorization` +alone, with no KDP in the picture; passing the vocabulary and the weights to +the constructor puts both in the config, where loading reads them. + +Rather than testing that one case, this walks every option that changes what +gets built and checks the round trip is exact. +""" + +import tempfile +import unittest +from pathlib import Path + +import keras +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel +from kdp.features import ( + CategoricalFeature, + DateFeature, + NumericalFeature, + PassthroughFeature, + TextFeature, +) +from kdp.processor import OutputModeOptions + +ROWS = 120 + +BASE = { + "num1": FeatureType.FLOAT_NORMALIZED, + "num2": FeatureType.FLOAT_RESCALED, + "cat1": FeatureType.STRING_CATEGORICAL, + "cat2": FeatureType.INTEGER_CATEGORICAL, + "txt": FeatureType.TEXT, + "date": FeatureType.DATE, +} + +# (name, feature overrides, model options) +CONFIGURATIONS = [ + ("plain", {}, {}), + ( + "one_hot", + { + "cat1": CategoricalFeature( + name="cat1", + feature_type=FeatureType.STRING_CATEGORICAL, + category_encoding="ONE_HOT_ENCODING", + ), + }, + {}, + ), + ( + "hashing", + { + "cat1": CategoricalFeature( + name="cat1", + feature_type=FeatureType.STRING_CATEGORICAL, + category_encoding="HASHING", + hash_bucket_size=8, + salt=7, + ), + }, + {}, + ), + ( + "discretized", + { + "num1": NumericalFeature( + name="num1", + feature_type=FeatureType.FLOAT_DISCRETIZED, + bin_boundaries=[-1.0, 0.0, 1.0], + ), + }, + {}, + ), + ( + "numeric_embedding", + { + "num1": NumericalFeature( + name="num1", + feature_type=FeatureType.FLOAT_NORMALIZED, + use_embedding=True, + embedding_dim=6, + ), + }, + {}, + ), + ( + "distribution_aware", + { + "num1": NumericalFeature( + name="num1", + feature_type=FeatureType.FLOAT_NORMALIZED, + use_distribution_aware=True, + ), + }, + {}, + ), + ( + "text_multi_hot", + {"txt": TextFeature(name="txt", output_mode="multi_hot")}, + {}, + ), + ("text_tf_idf", {"txt": TextFeature(name="txt", output_mode="tf_idf")}, {}), + ( + "text_ngrams", + {"txt": TextFeature(name="txt", output_mode="multi_hot", ngrams=2)}, + {}, + ), + ( + "text_capped", + {"txt": TextFeature(name="txt", output_mode="multi_hot", max_tokens=4)}, + {}, + ), + ("date_season", {"date": DateFeature(name="date", add_season=True)}, {}), + ("passthrough", {"num2": PassthroughFeature(name="num2", dtype=tf.float32)}, {}), + ("crosses", {}, {"feature_crosses": [("cat1", "cat2", 8)]}), + ( + "attention", + {}, + { + "tabular_attention": True, + "tabular_attention_heads": 2, + "tabular_attention_dim": 16, + }, + ), + ( + "transformer_categorical", + {}, + { + "transfo_nr_blocks": 2, + "transfo_nr_heads": 2, + "transfo_ff_units": 8, + "transfo_placement": "categorical", + }, + ), + ( + "transformer_all_features", + {}, + { + "transfo_nr_blocks": 2, + "transfo_nr_heads": 2, + "transfo_ff_units": 8, + "transfo_placement": "all_features", + }, + ), + ( + "feature_selection", + {}, + {"feature_selection_placement": "all_features", "feature_selection_units": 8}, + ), + ("feature_moe", {}, {"use_feature_moe": True, "feature_moe_num_experts": 3}), + ( + "global_numerical_embedding", + {}, + {"use_global_numerical_embedding": True, "global_embedding_dim": 6}, + ), + ("dict_mode", {}, {"output_mode": OutputModeOptions.DICT}), + ( + "dict_mode_feature_moe", + {}, + { + "output_mode": OutputModeOptions.DICT, + "use_feature_moe": True, + "feature_moe_num_experts": 3, + }, + ), +] + + +def _frame() -> pd.DataFrame: + generator = np.random.default_rng(41) + return pd.DataFrame( + { + "num1": generator.normal(0.0, 1.0, ROWS), + "num2": generator.uniform(0.0, 10.0, ROWS), + "cat1": generator.choice(["a", "b", "c"], ROWS), + "cat2": generator.integers(0, 5, ROWS), + "txt": generator.choice(["red car fast", "blue sky wide"], ROWS), + "date": pd.date_range("2020-01-01", periods=ROWS, freq="3D").strftime( + "%Y-%m-%d", + ), + }, + ) + + +def _feed(frame: pd.DataFrame) -> dict: + batch = {} + for column in frame.columns: + values = frame[column].to_numpy() + if values.dtype.kind == "f": + batch[column] = tf.constant(values.reshape(-1, 1), dtype=tf.float32) + elif values.dtype.kind in "iu": + batch[column] = tf.constant(values.reshape(-1, 1), dtype=tf.int32) + else: + batch[column] = tf.constant(values.reshape(-1, 1)) + return batch + + +def _flatten(result) -> np.ndarray: + if isinstance(result, dict): + return np.concatenate( + [np.asarray(result[key]).reshape(ROWS, -1) for key in sorted(result)], + axis=1, + ) + return np.asarray(result) + + +@pytest.mark.integration +class TestEveryConfigurationReloads(unittest.TestCase): + """Save, load, and compare the numbers, for every option that builds.""" + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + cls.frame = _frame() + cls.data = cls.directory / "data.csv" + cls.frame.to_csv(cls.data, index=False) + + def _round_trip(self, name: str, overrides: dict, options: dict) -> None: + specs = dict(BASE) + specs.update(overrides) + + keras.backend.clear_session() + preprocessor = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / f"stats_{name}.json"), + features_specs=specs, + overwrite_stats=True, + **options, + ) + preprocessor.build_preprocessor() + + batch = _feed(self.frame) + before = _flatten(preprocessor.model(batch)) + + path = self.directory / f"{name}.keras" + preprocessor.model.save(path) + after = _flatten(keras.models.load_model(path)(batch)) + + self.assertEqual(before.shape, after.shape, name) + np.testing.assert_allclose(before, after, atol=1e-5, err_msg=name) + + def test_every_configuration_round_trips(self) -> None: + for name, overrides, options in CONFIGURATIONS: + with self.subTest(configuration=name): + self._round_trip(name, overrides, options) + + +@pytest.mark.unit +class TestTfIdfKeepsItsWeights(unittest.TestCase): + """The rebuilt vectorizer has to compute what the adapted one computed.""" + + def test_it_matches_a_keras_layer_adapted_on_the_same_data(self) -> None: + directory = Path(tempfile.mkdtemp()) + sentences = [ + "red car fast", + "blue sky wide", + "red red red", + "green grass here", + ] + frame = pd.DataFrame( + {"txt": np.random.default_rng(41).choice(sentences, 200)}, + ) + data = directory / "data.csv" + frame.to_csv(data, index=False) + + preprocessor = PreprocessingModel( + path_data=str(data), + features_stats_path=str(directory / "stats.json"), + features_specs={"txt": TextFeature(name="txt", output_mode="tf_idf")}, + overwrite_stats=True, + output_mode=OutputModeOptions.DICT, + ) + preprocessor.build_preprocessor() + + probe = tf.constant(np.array(sentences).reshape(-1, 1)) + ours = np.asarray(preprocessor.model({"txt": probe})["txt"]) + + reference = keras.layers.TextVectorization(output_mode="tf_idf") + reference.adapt(tf.constant(frame["txt"].to_numpy())) + theirs = np.asarray(reference(tf.reshape(probe, [-1]))) + + self.assertEqual(ours.shape, theirs.shape) + np.testing.assert_allclose(ours, theirs, atol=1e-4) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_feature_crosses_reach_the_output.py b/test/test_feature_crosses_reach_the_output.py new file mode 100644 index 0000000..0819386 --- /dev/null +++ b/test/test_feature_crosses_reach_the_output.py @@ -0,0 +1,210 @@ +"""A cross has to appear in the output, and mean what a hashed cross means. + +`feature_crosses` built every cross it was given and then dropped it: +`_group_features_by_type` looks each processed feature up in `features_specs`, +a cross is configured with `feature_crosses` rather than by declaring a column, +so the lookup found nothing and skipped it. In the default output mode the +option was inert -- the model built, ran, and had exactly the width it would +have had with no crosses at all. + +The existing tests asserted that the model built and that its output was not +None, which was true throughout. These measure the cross itself: that it widens +the output, that its value depends on both columns and on nothing else, and +that it stays inside the number of bins it was given. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel +from kdp.processor import OutputModeOptions + +COLOURS = ["red", "green", "blue"] +SHAPES = ["circle", "square"] + +SPECS = { + "colour": FeatureType.STRING_CATEGORICAL, + "shape": FeatureType.STRING_CATEGORICAL, + "size": FeatureType.FLOAT_NORMALIZED, +} + + +def _frame(rows: int = 60) -> pd.DataFrame: + return pd.DataFrame( + { + "colour": [COLOURS[i % len(COLOURS)] for i in range(rows)], + "shape": [SHAPES[i % len(SHAPES)] for i in range(rows)], + "size": np.linspace(0.0, 10.0, rows), + }, + ) + + +def _call(model: tf.keras.Model, frame: pd.DataFrame) -> np.ndarray: + return np.asarray( + model({name: tf.constant(frame[name].values.reshape(-1, 1)) for name in frame}), + ) + + +@pytest.mark.unit +class TestFeatureCrossesReachTheOutput(unittest.TestCase): + """The cross has to survive as far as the tensor the caller receives.""" + + def setUp(self) -> None: + self.directory = Path(tempfile.mkdtemp()) + self.frame = _frame() + self.data = self.directory / "data.csv" + self.frame.to_csv(self.data, index=False) + + def _build(self, **kwargs) -> tf.keras.Model: + model = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / f"stats_{len(kwargs)}.json"), + features_specs=SPECS, + overwrite_stats=True, + **kwargs, + ) + return model.build_preprocessor()["model"] + + def test_a_cross_makes_the_output_wider(self) -> None: + """One crossed column per cross, on top of the declared features.""" + plain = _call(self._build(), self.frame).shape[1] + crossed = _call( + self._build(feature_crosses=[("colour", "shape", 16)]), + self.frame, + ).shape[1] + self.assertEqual( + crossed, + plain + 1, + "the cross never reached the concatenated output", + ) + + def test_two_crosses_add_two_columns(self) -> None: + plain = _call(self._build(), self.frame).shape[1] + crossed = _call( + self._build( + feature_crosses=[("colour", "shape", 16), ("shape", "colour", 8)], + ), + self.frame, + ).shape[1] + self.assertEqual(crossed, plain + 2) + + def test_the_cross_is_a_function_of_both_columns_and_of_nothing_else(self) -> None: + """Every distinct pair gets its own bin; other columns do not move it.""" + width = _call(self._build(), self.frame).shape[1] + model = self._build(feature_crosses=[("colour", "shape", 64)]) + probe = pd.DataFrame( + { + "colour": ["red", "red", "green", "green", "red"], + "shape": ["circle", "square", "circle", "square", "circle"], + # The last row repeats the first pair with a different size. + "size": [1.0, 1.0, 1.0, 1.0, 9.0], + }, + ) + crossed = _call(model, probe)[:, width:] + + self.assertEqual( + len({tuple(row) for row in crossed[:4]}), + 4, + "distinct (colour, shape) pairs collided into one bin", + ) + np.testing.assert_allclose( + crossed[0], + crossed[4], + err_msg="the cross moved when a column it does not cross changed", + ) + + def test_the_cross_stays_inside_the_number_of_bins(self) -> None: + rows = 120 + frame = pd.DataFrame( + { + "colour": [f"colour_{index}" for index in range(rows)], + "shape": [f"shape_{index % 7}" for index in range(rows)], + "size": np.linspace(0.0, 1.0, rows), + }, + ) + data = self.directory / "wide.csv" + frame.to_csv(data, index=False) + + def build(**kwargs) -> tf.keras.Model: + model = PreprocessingModel( + path_data=str(data), + features_stats_path=str(self.directory / f"wide_{len(kwargs)}.json"), + features_specs=SPECS, + overwrite_stats=True, + **kwargs, + ) + return model.build_preprocessor()["model"] + + width = _call(build(), frame).shape[1] + crossed = _call(build(feature_crosses=[("colour", "shape", 5)]), frame)[ + :, + width:, + ] + self.assertGreaterEqual(crossed.min(), 0.0) + self.assertLess(crossed.max(), 5.0) + self.assertGreater( + len(np.unique(crossed)), + 1, + "every pair hashed to the same bin", + ) + + def test_the_cross_is_a_key_in_dict_mode(self) -> None: + model = self._build( + feature_crosses=[("colour", "shape", 16)], + output_mode=OutputModeOptions.DICT, + ) + outputs = model( + { + name: tf.constant(self.frame[name].values.reshape(-1, 1)) + for name in self.frame + }, + ) + self.assertIn("colour_x_shape", outputs) + + +@pytest.mark.unit +class TestFeatureCrossesRefuseWhatTheyCannotDo(unittest.TestCase): + """A cross that cannot run has to say so while the config is still in view.""" + + def setUp(self) -> None: + self.directory = Path(tempfile.mkdtemp()) + self.data = self.directory / "data.csv" + _frame().to_csv(self.data, index=False) + + def _build(self, crosses: list) -> tf.keras.Model: + model = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / "stats.json"), + features_specs=SPECS, + overwrite_stats=True, + feature_crosses=crosses, + ) + return model.build_preprocessor()["model"] + + def test_crossing_a_float_column_is_refused_at_build_time(self) -> None: + """`HashedCrossing` takes integers and strings; floats used to build. + + The model came back looking correct -- summary, output width, the lot -- + and raised on the first batch it was ever given. + """ + with self.assertRaises(ValueError) as raised: + self._build([("colour", "size", 8)]) + message = str(raised.exception) + self.assertIn("size", message) + self.assertIn("float32", message) + + def test_crossing_an_undeclared_feature_names_it(self) -> None: + with self.assertRaises(ValueError) as raised: + self._build([("colour", "not_a_column", 8)]) + self.assertIn("not_a_column", str(raised.exception)) + self.assertIn("features_specs", str(raised.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_float32_collapse_is_reported.py b/test/test_float32_collapse_is_reported.py new file mode 100644 index 0000000..1a57008 --- /dev/null +++ b/test/test_float32_collapse_is_reported.py @@ -0,0 +1,142 @@ +"""A column float32 cannot hold has to be called out, not silently flattened. + +Everything after the CSV reader is float32, which carries about seven +significant digits. A column whose values sit far from zero and vary only +slightly loses that variation on the way in: Unix timestamps in seconds are 128 +apart in float32 near 1.6e9, so a column spread over half a minute arrives as +one or two distinct numbers. Normalization then works perfectly on what is +left, the reported standard deviation is 1.0, and the feature the model sees is +a constant. + +The numbers are not wrong -- they describe the values the file really produced. +The loss is upstream and cannot be undone here, so this is a warning naming the +column, and it has to fire for exactly the columns that are hurt. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf +from loguru import logger + +from kdp import FeatureType, PreprocessingModel +from kdp.stats import DatasetStatistics + +ROWS = 3000 + + +def _frame() -> pd.DataFrame: + generator = np.random.default_rng(7) + return pd.DataFrame( + { + # Unix seconds, spread over well under a minute. + "epoch_seconds": generator.normal(1.6e9, 30.0, ROWS), + # Days since the epoch: far from zero, but coarse enough to survive. + "epoch_days": generator.normal(19_000.0, 300.0, ROWS), + # Money in cents, up to ten million. + "price_cents": generator.integers(0, 10**7, ROWS).astype(float), + "ordinary": generator.normal(0.0, 1.0, ROWS), + "constant": np.full(ROWS, 42.0), + }, + ) + + +class _Captured: + """Collect loguru warnings raised while the block runs.""" + + def __init__(self) -> None: + self.messages: list[str] = [] + self._handler: int | None = None + + def __enter__(self) -> "_Captured": + self._handler = logger.add( + lambda message: self.messages.append(str(message)), + level="WARNING", + ) + return self + + def __exit__(self, *_) -> None: + if self._handler is not None: + logger.remove(self._handler) + + +@pytest.mark.unit +class TestFloat32CollapseIsReported(unittest.TestCase): + """The warning has to name the column that is lost, and only that one.""" + + def setUp(self) -> None: + self.directory = Path(tempfile.mkdtemp()) + self.frame = _frame() + self.data = self.directory / "data.csv" + self.frame.to_csv(self.data, index=False) + + def _run_statistics(self) -> _Captured: + with _Captured() as captured: + DatasetStatistics( + path_data=str(self.data), + features_stats_path=str(self.directory / "stats.json"), + features_specs={ + name: FeatureType.FLOAT_NORMALIZED for name in self.frame + }, + overwrite_stats=True, + ).main() + return captured + + def test_the_column_float32_destroys_is_named(self) -> None: + warnings = [m for m in self._run_statistics().messages if "float32" in m] + self.assertEqual(len(warnings), 1, warnings) + self.assertIn("epoch_seconds", warnings[0]) + + def test_columns_float32_can_hold_are_left_alone(self) -> None: + warnings = [m for m in self._run_statistics().messages if "float32" in m] + joined = " ".join(warnings) + for name in ("epoch_days", "price_cents", "ordinary", "constant"): + self.assertNotIn(name, joined) + + +@pytest.mark.unit +class TestTheWarningDescribesWhatActuallyHappens(unittest.TestCase): + """The claim in the message has to hold end to end.""" + + def test_the_named_column_really_does_collapse(self) -> None: + directory = Path(tempfile.mkdtemp()) + frame = _frame() + data = directory / "data.csv" + frame.to_csv(data, index=False) + + preprocessor = PreprocessingModel( + path_data=str(data), + features_stats_path=str(directory / "stats.json"), + features_specs={name: FeatureType.FLOAT_NORMALIZED for name in frame}, + overwrite_stats=True, + output_mode="dict", + ) + preprocessor.build_preprocessor() + outputs = preprocessor.model( + { + name: tf.constant(frame[name].values.reshape(-1, 1), dtype=tf.float32) + for name in frame + }, + ) + + collapsed = np.unique(np.asarray(outputs["epoch_seconds"])) + self.assertLess( + len(collapsed), + 10, + "the warning claims this column collapses, and it did not", + ) + for name in ("epoch_days", "price_cents", "ordinary"): + kept = np.unique(np.asarray(outputs[name])) + self.assertGreater( + len(kept), + ROWS // 2, + f"{name} was not warned about but lost most of its values", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_max_tokens_caps_the_vocabulary.py b/test/test_max_tokens_caps_the_vocabulary.py new file mode 100644 index 0000000..e361970 --- /dev/null +++ b/test/test_max_tokens_caps_the_vocabulary.py @@ -0,0 +1,112 @@ +"""`max_tokens` has to cap the vocabulary, which is the only reason to set it. + +The statistics pass collects every distinct word in the column and the layer was +handed all of them alongside the cap. Keras refuses that outright -- "Attempted +to set a vocabulary larger than the maximum vocab size" -- so asking for a +vocabulary smaller than the data holds could not build at all, while asking for +a larger one worked and did nothing. The option was usable only where it had no +effect. + +The statistics carry no word counts to choose the survivors with, so when the +cap bites the vectorizer adapts on the column instead, which is how `max_tokens` +picks its words everywhere else in Keras: by frequency. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel +from kdp.features import TextFeature +from kdp.processor import OutputModeOptions + +ROWS = 150 +SENTENCES = [ + "red car fast", + "blue sky wide open", + "green grass", + "red red red", +] + + +@pytest.mark.unit +class TestMaxTokensCapsTheVocabulary(unittest.TestCase): + """The output width has to follow the cap, above and below the data.""" + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + generator = np.random.default_rng(13) + cls.frame = pd.DataFrame({"txt": generator.choice(SENTENCES, ROWS)}) + cls.data = cls.directory / "data.csv" + cls.frame.to_csv(cls.data, index=False) + # "red car fast blue sky wide open green grass" -- nine distinct words. + cls.distinct_words = len( + {word for sentence in SENTENCES for word in sentence.split()}, + ) + + def _width(self, **kwargs) -> int: + feature = TextFeature( + name="txt", + feature_type=FeatureType.TEXT, + output_mode="multi_hot", + **kwargs, + ) + preprocessor = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / f"stats_{kwargs}.json"), + features_specs={"txt": feature}, + overwrite_stats=True, + output_mode=OutputModeOptions.DICT, + ) + preprocessor.build_preprocessor() + result = preprocessor.model( + {"txt": tf.constant(self.frame["txt"].values[:5].reshape(-1, 1))}, + ) + return int(np.asarray(result["txt"]).shape[1]) + + def test_a_cap_below_the_data_is_honoured(self) -> None: + for cap in (3, 5, 7): + with self.subTest(max_tokens=cap): + self.assertLessEqual(self._width(max_tokens=cap), cap) + + def test_a_cap_below_the_data_still_produces_a_usable_vocabulary(self) -> None: + self.assertGreater(self._width(max_tokens=4), 1) + + def test_a_cap_above_the_data_keeps_every_word(self) -> None: + width = self._width(max_tokens=500) + self.assertGreaterEqual(width, self.distinct_words) + + def test_the_int_mode_reserves_its_two_slots(self) -> None: + """`int` keeps a padding slot as well as the out-of-vocabulary one.""" + feature = TextFeature( + name="txt", + feature_type=FeatureType.TEXT, + output_mode="int", + output_sequence_length=6, + max_tokens=4, + ) + preprocessor = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / "stats_int.json"), + features_specs={"txt": feature}, + overwrite_stats=True, + output_mode=OutputModeOptions.DICT, + ) + preprocessor.build_preprocessor() + result = np.asarray( + preprocessor.model( + {"txt": tf.constant(self.frame["txt"].values[:5].reshape(-1, 1))}, + )["txt"], + ) + self.assertEqual(result.shape[1], 6) + self.assertLess(result.max(), 4, "a token index landed outside max_tokens") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_path_data_selects_one_file.py b/test/test_path_data_selects_one_file.py new file mode 100644 index 0000000..47018f0 --- /dev/null +++ b/test/test_path_data_selects_one_file.py @@ -0,0 +1,187 @@ +"""`path_data` pointing at a file has to mean that file. + +The path was replaced with `/*.csv`, so naming one CSV computed the +statistics over every CSV beside it. Every example in the documentation -- +`path_data="data/my_data.csv"` in the README included -- passes a single file, +and a project laid out as `data/train.csv` and `data/test.csv` therefore had its +test set folded into the training statistics. Nothing raised: the numbers came +back, drawn from the wrong rows. +""" + +import json +import zlib +import tempfile +import unittest +from pathlib import Path + +import keras +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel +from kdp.stats import DatasetStatistics + +ROWS = 400 + + +def _write(directory, name, mean): + """A CSV whose column sits at a known, distinctive mean.""" + # `hash()` on a string is salted per process, so seeding from it drew + # different numbers in every pytest-xdist worker. + frame = pd.DataFrame( + { + "x": np.random.default_rng(zlib.crc32(name.encode())).normal( + mean, + 1.0, + ROWS, + ), + }, + ) + path = Path(directory) / name + frame.to_csv(path, index=False) + return path, float(frame["x"].mean()) + + +@pytest.mark.unit +class TestPathDataSelectsOneFile(unittest.TestCase): + """What a file, a directory and a glob each select.""" + + def test_naming_a_file_ignores_its_neighbours(self): + with tempfile.TemporaryDirectory() as directory: + train, train_mean = _write(directory, "train.csv", mean=10.0) + _write(directory, "test.csv", mean=1000.0) + + stats_path = Path(directory) / "stats.json" + keras.backend.clear_session() + preprocessor = PreprocessingModel( + path_data=str(train), + features_specs={"x": FeatureType.FLOAT_NORMALIZED}, + features_stats_path=str(stats_path), + overwrite_stats=True, + ) + preprocessor.build_preprocessor() + stats = json.loads(stats_path.read_text())["numeric_stats"]["x"] + + # Only train.csv: its own row count and its own mean, nowhere near the + # 505 that averaging the two files would give. + self.assertEqual(int(stats["count"]), ROWS) + self.assertAlmostEqual(float(stats["mean"]), train_mean, places=3) + + def test_naming_a_directory_reads_every_csv_in_it(self): + with tempfile.TemporaryDirectory() as directory: + _, mean_a = _write(directory, "a.csv", mean=10.0) + _, mean_b = _write(directory, "b.csv", mean=20.0) + + stats_path = Path(directory) / "stats.json" + keras.backend.clear_session() + preprocessor = PreprocessingModel( + path_data=directory, + features_specs={"x": FeatureType.FLOAT_NORMALIZED}, + features_stats_path=str(stats_path), + overwrite_stats=True, + ) + preprocessor.build_preprocessor() + stats = json.loads(stats_path.read_text())["numeric_stats"]["x"] + + self.assertEqual(int(stats["count"]), 2 * ROWS) + # Against the mean the two files actually hold, not the 15.0 they were + # drawn around: with 400 rows each the sample mean wanders by about + # 0.035, which is wider than the tolerance this once asserted. + self.assertAlmostEqual( + float(stats["mean"]), + (mean_a + mean_b) / 2.0, + places=3, + ) + + def test_the_three_shapes_a_path_can_take(self): + with tempfile.TemporaryDirectory() as directory: + path, _ = _write(directory, "one.csv", mean=5.0) + self.assertEqual( + DatasetStatistics._get_csv_file_pattern(str(path)), + str(path), + ) + self.assertEqual( + DatasetStatistics._get_csv_file_pattern(directory), + str(Path(directory) / "*.csv"), + ) + glob = str(Path(directory) / "*.csv") + self.assertEqual(DatasetStatistics._get_csv_file_pattern(glob), glob) + + def test_a_path_that_does_not_exist_is_reported(self): + """`tf.data` answers a missing file with an empty dataset, and the + statistics then come from nothing at all.""" + with tempfile.TemporaryDirectory() as directory: + missing = str(Path(directory) / "absent.csv") + with self.assertRaises(FileNotFoundError) as caught: + DatasetStatistics._get_csv_file_pattern(missing) + self.assertIn("absent.csv", str(caught.exception)) + + def test_two_models_built_in_one_process_keep_their_own_statistics(self): + """The symptom this surfaced as: the second model's numbers were the + two datasets pooled together.""" + with tempfile.TemporaryDirectory() as directory: + first, first_mean = _write(directory, "first.csv", mean=10.0) + second, second_mean = _write(directory, "second.csv", mean=900.0) + + seen = [] + for index, (path, expected) in enumerate( + ((first, first_mean), (second, second_mean)), + ): + stats_path = Path(directory) / f"stats{index}.json" + keras.backend.clear_session() + preprocessor = PreprocessingModel( + path_data=str(path), + features_specs={"x": FeatureType.FLOAT_NORMALIZED}, + features_stats_path=str(stats_path), + overwrite_stats=True, + ) + preprocessor.build_preprocessor() + stats = json.loads(stats_path.read_text())["numeric_stats"]["x"] + seen.append((int(stats["count"]), float(stats["mean"]), expected)) + + for count, mean, expected in seen: + self.assertEqual(count, ROWS) + self.assertAlmostEqual(mean, expected, places=3) + + +@pytest.mark.unit +class TestLargeMagnitudeColumnsSurviveNormalisation(unittest.TestCase): + """IDs, epoch timestamps and amounts in cents all live far from zero.""" + + COLUMNS = { + "ordinary": (50.0, 12.0), + "large ids": (1e8, 1e3), + "amounts in cents": (1e7, 5e2), + "epoch seconds": (1.7e9, 1e6), + } + + def test_a_column_far_from_zero_still_standardises(self): + rng = np.random.default_rng(1) + for label, (centre, spread) in self.COLUMNS.items(): + with self.subTest(column=label), tempfile.TemporaryDirectory() as directory: + column = rng.normal(centre, spread, 2000) + path = Path(directory) / "d.csv" + pd.DataFrame({"x": column}).to_csv(path, index=False) + + keras.backend.clear_session() + preprocessor = PreprocessingModel( + path_data=str(path), + features_specs={"x": FeatureType.FLOAT_NORMALIZED}, + features_stats_path=str(Path(directory) / "s.json"), + overwrite_stats=True, + ) + preprocessor.build_preprocessor() + output = np.asarray( + preprocessor.model( + {"x": tf.constant(column.reshape(-1, 1), dtype=tf.float32)}, + training=False, + ), + ) + + self.assertFalse(np.isnan(output).any(), label) + # The whole point of FLOAT_NORMALIZED: zero mean, unit variance. + # These columns used to come out as a constant. + self.assertLess(abs(float(output.mean())), 0.05, label) + self.assertAlmostEqual(float(output.std()), 1.0, delta=0.05, msg=label) diff --git a/test/test_predict_accepts_what_it_documents.py b/test/test_predict_accepts_what_it_documents.py new file mode 100644 index 0000000..6c4efa4 --- /dev/null +++ b/test/test_predict_accepts_what_it_documents.py @@ -0,0 +1,267 @@ +"""`predict()` has to take the shapes its own docstring offers. + +The docstring says the data "can be pandas DataFrame, dict, or TensorFlow +dataset". A DataFrame went straight to Keras, which reads a frame as a block of +floats and raised `could not convert string to float` on the first categorical +column. A dict of flat lists -- the natural spelling, and exactly what +`InferenceFormatter` produces -- converted to shape (N,) where every input is +declared (N, 1), and failed with `as_list() is not defined on an unknown +TensorShape`. Both carried the right values; only the container was wrong. + +Every accepted spelling has to give the same numbers as calling the model with +tensors, because that is the whole claim. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel + +ROWS = 24 + +SPECS = { + "age": FeatureType.FLOAT_NORMALIZED, + "city": FeatureType.STRING_CATEGORICAL, + "tier": FeatureType.INTEGER_CATEGORICAL, + "signed": FeatureType.DATE, +} + + +def _frame() -> pd.DataFrame: + generator = np.random.default_rng(0) + return pd.DataFrame( + { + "age": generator.normal(40.0, 8.0, ROWS), + "city": generator.choice(["paris", "lisbon", "oslo"], ROWS), + "tier": generator.integers(0, 4, ROWS), + "signed": pd.date_range("2021-01-01", periods=ROWS, freq="9D").strftime( + "%Y-%m-%d", + ), + }, + ) + + +@pytest.mark.unit +class TestPredictAcceptsWhatItDocuments(unittest.TestCase): + """Every documented container has to reach the same tensor.""" + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + cls.frame = _frame() + data = cls.directory / "data.csv" + cls.frame.to_csv(data, index=False) + cls.preprocessor = PreprocessingModel( + path_data=str(data), + features_stats_path=str(cls.directory / "stats.json"), + features_specs=SPECS, + overwrite_stats=True, + ) + cls.preprocessor.build_preprocessor() + cls.reference = np.asarray( + cls.preprocessor.model( + { + name: tf.constant(cls.frame[name].values.reshape(-1, 1)) + for name in cls.frame + }, + ), + ) + + def _assert_matches(self, payload) -> None: + got = np.asarray(self.preprocessor.predict(payload, verbose=0)) + self.assertEqual(got.shape, self.reference.shape) + np.testing.assert_allclose(got, self.reference, atol=1e-5) + + def test_a_dataframe(self) -> None: + self._assert_matches(self.frame) + + def test_a_dict_of_flat_lists(self) -> None: + self._assert_matches({name: self.frame[name].tolist() for name in self.frame}) + + def test_a_dict_of_column_lists(self) -> None: + self._assert_matches( + {name: [[value] for value in self.frame[name]] for name in self.frame}, + ) + + def test_a_dict_of_numpy_columns(self) -> None: + self._assert_matches( + {name: self.frame[name].values.reshape(-1, 1) for name in self.frame}, + ) + + def test_a_tensorflow_dataset(self) -> None: + dataset = tf.data.Dataset.from_tensor_slices(dict(self.frame)).batch(8) + self._assert_matches(dataset) + + def test_a_single_row_as_a_dataframe(self) -> None: + got = np.asarray(self.preprocessor.predict(self.frame.iloc[:1], verbose=0)) + np.testing.assert_allclose(got, self.reference[:1], atol=1e-5) + + def test_what_the_inference_formatter_produces(self) -> None: + """The formatter's own output is flat, and has to be accepted.""" + from kdp import InferenceFormatter + + formatter = InferenceFormatter(self.preprocessor) + prepared = formatter.prepare_inference_data(self.frame, to_tensors=True) + self._assert_matches(prepared) + + +@pytest.mark.unit +class TestAGapInTheFrameDoesNotStopIt(unittest.TestCase): + """A frame with missing values is what real data looks like. + + pandas gives a column mixing NaN with strings the object dtype, and Keras + refuses that outright with `Invalid dtype: object`. `InferenceFormatter` + already decided each column's type from the values actually present; + `predict()` did not, so the two documented ways in disagreed. + """ + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + cls.frame = _frame() + data = cls.directory / "data.csv" + cls.frame.to_csv(data, index=False) + cls.preprocessor = PreprocessingModel( + path_data=str(data), + features_stats_path=str(cls.directory / "stats.json"), + features_specs=SPECS, + overwrite_stats=True, + ) + cls.preprocessor.build_preprocessor() + + def _with_gaps(self) -> pd.DataFrame: + # Not the date: that column is the one kind of gap the library refuses, + # and the test below is about the message it refuses with. + gappy = self.frame.iloc[:5].copy() + gappy.loc[gappy.index[0], "age"] = np.nan + gappy.loc[gappy.index[1], "city"] = None + gappy.loc[gappy.index[2], "tier"] = np.nan + return gappy + + def test_a_frame_with_gaps_goes_through(self) -> None: + result = np.asarray(self.preprocessor.predict(self._with_gaps(), verbose=0)) + self.assertEqual(result.shape[0], 5) + + def test_a_missing_category_does_not_produce_a_non_finite_number(self) -> None: + """Only the numeric gap may carry NaN; the rest map to their OOV slot.""" + gappy = self.frame.iloc[:4].copy() + gappy.loc[gappy.index[0], "city"] = None + gappy.loc[gappy.index[1], "tier"] = np.nan + result = np.asarray(self.preprocessor.predict(gappy, verbose=0)) + self.assertTrue(np.isfinite(result).all()) + + def test_a_missing_date_says_what_is_wrong_and_which_value(self) -> None: + """A date column cannot hold nulls, and has to say so where it fails. + + The formatter turns any missing string into the empty string, which the + date parser refuses. It used to refuse it as a TensorFlow graph error + naming an internal assertion node and nothing else. + """ + gappy = self.frame.iloc[:3].copy() + gappy.loc[gappy.index[1], "signed"] = None + with self.assertRaises(Exception) as raised: # noqa: B017 -- tf error type + self.preprocessor.predict(gappy, verbose=0) + message = " ".join(str(raised.exception).split()) + self.assertIn("date column cannot hold nulls", message) + + def test_a_missing_number_stays_inside_its_own_column(self) -> None: + gappy = self.frame.iloc[:4].copy() + gappy.loc[gappy.index[1], "age"] = np.nan + result = np.asarray(self.preprocessor.predict(gappy, verbose=0)) + rows = {int(row) for row in np.argwhere(~np.isfinite(result))[:, 0]} + self.assertEqual(rows, {1}, "the gap spread beyond the row it was in") + self.assertEqual(int((~np.isfinite(result)).sum()), 1) + + def test_both_documented_paths_agree_about_a_gap(self) -> None: + from kdp import InferenceFormatter + + gappy = self._with_gaps() + through_predict = np.asarray(self.preprocessor.predict(gappy, verbose=0)) + prepared = InferenceFormatter(self.preprocessor).prepare_inference_data( + gappy, + to_tensors=True, + ) + through_formatter = np.asarray(self.preprocessor.model(prepared)) + np.testing.assert_array_equal( + np.isfinite(through_predict), + np.isfinite(through_formatter), + ) + finite = np.isfinite(through_predict) + np.testing.assert_allclose( + through_predict[finite], + through_formatter[finite], + atol=1e-5, + ) + + +@pytest.mark.unit +class TestPredictIsStableAcrossHowItIsCalled(unittest.TestCase): + """The same row has to come back the same, however it is fed in.""" + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + cls.frame = _frame() + data = cls.directory / "data.csv" + cls.frame.to_csv(data, index=False) + preprocessor = PreprocessingModel( + path_data=str(data), + features_stats_path=str(cls.directory / "stats.json"), + features_specs=SPECS, + overwrite_stats=True, + ) + preprocessor.build_preprocessor() + cls.model = preprocessor.model + cls.reference = cls._call(cls.frame) + + @classmethod + def _call(cls, frame: pd.DataFrame) -> np.ndarray: + return np.asarray( + cls.model( + { + name: tf.constant(frame[name].values.reshape(-1, 1)) + for name in frame + }, + ), + ) + + def test_batch_size_does_not_change_a_row(self) -> None: + pieces = [self._call(self.frame.iloc[i : i + 5]) for i in range(0, ROWS, 5)] + np.testing.assert_allclose( + np.concatenate(pieces), + self.reference, + atol=1e-5, + ) + + def test_row_order_does_not_change_a_row(self) -> None: + order = np.random.default_rng(1).permutation(ROWS) + shuffled = self._call(self.frame.iloc[order].reset_index(drop=True)) + np.testing.assert_allclose(shuffled, self.reference[order], atol=1e-5) + + def test_calling_twice_gives_the_same_answer(self) -> None: + np.testing.assert_array_equal(self._call(self.frame), self.reference) + + def test_a_single_row_matches_its_place_in_the_batch(self) -> None: + np.testing.assert_allclose( + self._call(self.frame.iloc[:1]), + self.reference[:1], + atol=1e-5, + ) + + def test_values_never_seen_in_training_still_produce_finite_numbers(self) -> None: + unseen = self.frame.iloc[:4].copy() + unseen["city"] = ["atlantis", "paris", "", "oslo"] + unseen["tier"] = [999, 0, -7, 1] + result = self._call(unseen) + self.assertEqual(result.shape, (4, self.reference.shape[1])) + self.assertTrue(np.isfinite(result).all()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_processor.py b/test/test_processor.py index 0133410..d4430e7 100644 --- a/test/test_processor.py +++ b/test/test_processor.py @@ -549,10 +549,21 @@ def test_date_feature_preprocessing(self): ) test_data.to_csv(self._path_data, index=False) - # Verify preprocessing works - dataset = tf.data.Dataset.from_tensor_slices(dict(test_data)) - preprocessed = model.batch_predict(dataset) - self.assertIsNotNone(preprocessed) + # Verify preprocessing works. `batch_predict` is a generator, so + # holding onto it and asserting it is not None never ran the model. + dataset = tf.data.Dataset.from_tensor_slices(dict(test_data)).batch(4) + batches = list(model.batch_predict(dataset)) + self.assertTrue(batches) + # Dict output mode: each batch is a mapping of feature name to tensor. + rows = 0 + for batch in batches: + tensors = list(batch.values()) if isinstance(batch, dict) else [batch] + widths = {int(np.asarray(tensor).shape[0]) for tensor in tensors} + self.assertEqual(len(widths), 1, f"features disagree on rows: {widths}") + rows += widths.pop() + for tensor in tensors: + self.assertTrue(np.isfinite(np.asarray(tensor)).all()) + self.assertEqual(rows, len(test_data)) def test_caching_functionality(self): """Test the caching functionality of preprocessed features.""" @@ -695,16 +706,24 @@ def test_end_to_end_feature_combinations(self): # Create a small batch of test data test_data = generate_fake_data(test_case["features"], num_rows=5) - dataset = tf.data.Dataset.from_tensor_slices(dict(test_data)) + dataset = tf.data.Dataset.from_tensor_slices(dict(test_data)).batch(5) - # Test preprocessing - preprocessed = ppr.batch_predict(dataset) - self.assertIsNotNone(preprocessed) + # Test preprocessing. `batch_predict` is a generator: the + # assertion below used to be made against the generator object + # rather than anything it produced, so the model never ran. + batches = [np.asarray(batch) for batch in ppr.batch_predict(dataset)] + self.assertTrue(batches) + preprocessed = np.concatenate(batches) + self.assertEqual(preprocessed.shape[0], 5) + self.assertTrue(np.isfinite(preprocessed).all()) # Additional checks based on feature combination if "date1" in test_case["features"]: date_feature = test_case["features"]["date1"] - if getattr(date_feature, "add_season", False): + # `add_season` lives in the feature's kwargs, not as an + # attribute, so `getattr` here always answered False and + # this check never ran either. + if date_feature.kwargs.get("add_season", False): # Check if output shape includes seasonal encoding self.assertGreaterEqual( preprocessed.shape[-1], 4 @@ -737,9 +756,15 @@ def test_date_feature_variations(self): ), }, { + # Dates are read as year, then month, then day. This used to + # ask for "%m/%d/%Y" and feed month-first strings, which the + # parser cannot read -- the case passed because nothing ever + # consumed the generator below. "name": "custom_format_date", "config": DateFeature( - name="date", feature_type=FeatureType.DATE, date_format="%m/%d/%Y" + name="date", + feature_type=FeatureType.DATE, + date_format="%Y-%m-%d %H:%M:%S", ), }, { @@ -771,13 +796,18 @@ def test_date_feature_variations(self): # Test with specific date formats if config["name"] == "custom_format_date": - test_data = pd.DataFrame({"date": ["01/15/2023", "12/31/2022"]}) + test_data = pd.DataFrame( + {"date": ["2023-01-15 08:30:00", "2022-12-31 23:59:59"]}, + ) else: test_data = pd.DataFrame({"date": ["2023-01-15", "2022-12-31"]}) - dataset = tf.data.Dataset.from_tensor_slices(dict(test_data)) - preprocessed = ppr.batch_predict(dataset) - self.assertIsNotNone(preprocessed) + dataset = tf.data.Dataset.from_tensor_slices(dict(test_data)).batch(2) + batches = [np.asarray(batch) for batch in ppr.batch_predict(dataset)] + self.assertTrue(batches) + preprocessed = np.concatenate(batches) + self.assertEqual(preprocessed.shape[0], 2) + self.assertTrue(np.isfinite(preprocessed).all()) def test_preprocessor_with_tabular_attention(self): """Test end-to-end preprocessing with TabularAttention.""" @@ -926,7 +956,7 @@ def test_preprocessor_attention_with_feature_crosses(self): tabular_attention_heads=4, tabular_attention_dim=64, tabular_attention_dropout=0.1, - feature_crosses=[("num1", "num2", 5), ("cat1", "cat2", 5)], + feature_crosses=[("cat1", "cat2", 5)], output_mode=OutputModeOptions.CONCAT, ) @@ -1365,7 +1395,11 @@ def test_preprocessor_all_features_with_transformer_and_attention_with_distribut self.assertIsNotNone(preprocessed) # Check output dimensions - self.assertEqual(len(preprocessed.shape), 3) # (batch_size, d_model) + # `transfo_placement="all_features"` was the one configuration that came + # out rank 3: a transformer block adds a sequence axis and this branch + # never took it off again. Every other placement, this test's own + # comment, and its three siblings all say (rows, width). + self.assertEqual(len(preprocessed.shape), 2) # (batch_size, d_model) self.assertEqual(preprocessed.shape[-1], 23) # Example dimension def test_preprocessor_all_features_with_transformer_and_attention_with_distribution_aware_v4( @@ -1791,9 +1825,14 @@ def test_preprocessor_parameter_combinations(self): if test_case["output_mode"] == OutputModeOptions.CONCAT: if test_case["tabular_attention"]: - # Check output dimensions for concatenated output with attention + # Check output dimensions for concatenated output with attention. + # This case is attention plus `transfo_placement="all_features"`, + # the one combination that used to come back rank 3 + # because that branch never removed the sequence axis a + # transformer block adds. Attention on its own has + # always flattened, like every other placement. self.assertEqual( - len(preprocessed.shape), 3 + len(preprocessed.shape), 2 ) # (batch_size, d_model) self.assertEqual( preprocessed.shape[-1], test_case["tabular_attention_dim"] diff --git a/test/test_stats_accumulators.py b/test/test_stats_accumulators.py index f9c4f54..471ef34 100644 --- a/test/test_stats_accumulators.py +++ b/test/test_stats_accumulators.py @@ -13,7 +13,7 @@ import pytest import tensorflow as tf -from kdp.stats import DateAccumulator, TextAccumulator +from kdp.stats import DateAccumulator, TextAccumulator, WelfordAccumulator @pytest.mark.unit @@ -136,3 +136,269 @@ def test_a_single_date_has_no_spread(self): if __name__ == "__main__": unittest.main() + + +@pytest.mark.unit +class TestWelfordAccumulator(unittest.TestCase): + """The mean and variance every normalised column is built from. + + This accumulated in float32 and subtracted the running mean from each raw + value, so the deviations were computed at the magnitude of the values + themselves. A column around 1e8 came back with a variance of *minus* 1.6e6 + against a true 1.0e6; one with a spread of 1e-4 was wrong by four orders of + magnitude. `Normalization` divides by the root of that number, so the + column reached the model as a constant and nothing raised. + """ + + CASES = { + "ordinary": lambda rng: rng.normal(50, 12, 5000), + "large magnitude": lambda rng: rng.normal(1e8, 1e3, 5000), + "small magnitude": lambda rng: rng.normal(1e-6, 1e-7, 5000), + "heavy tail": lambda rng: rng.standard_t(2, 5000) * 100, + "constant": lambda rng: np.full(5000, 42.0), + } + + @staticmethod + def _accumulate(data, chunks=37, dtype=tf.float64): + accumulator = WelfordAccumulator() + for chunk in np.array_split(data, chunks): + accumulator.update(tf.constant(chunk, dtype=dtype)) + return accumulator + + def test_mean_and_variance_match_numpy(self): + rng = np.random.default_rng(3) + for label, make in self.CASES.items(): + with self.subTest(case=label): + data = make(rng) + accumulator = self._accumulate(data) + mean = float(accumulator.mean.numpy()) + variance = float(accumulator.variance.numpy()) + self.assertAlmostEqual( + mean / max(abs(float(np.mean(data))), 1e-30), + 1.0 if np.mean(data) != 0 else mean, + places=6, + ) + expected = float(np.var(data, ddof=1)) + if expected == 0.0: + self.assertAlmostEqual(variance, 0.0, places=12) + else: + self.assertLess(abs(variance - expected) / expected, 1e-6, label) + + def test_variance_is_never_negative(self): + """A negative variance is not a number a variance can be.""" + rng = np.random.default_rng(11) + for label, make in self.CASES.items(): + with self.subTest(case=label): + accumulator = self._accumulate(make(rng)) + self.assertGreaterEqual(float(accumulator.variance.numpy()), 0.0, label) + + def test_the_answer_does_not_depend_on_how_the_data_is_batched(self): + """One row at a time or all at once has to give the same statistics.""" + data = np.random.default_rng(5).normal(1e7, 250, 900) + results = [ + ( + float(self._accumulate(data, chunks=c).mean.numpy()), + float(self._accumulate(data, chunks=c).variance.numpy()), + ) + for c in (1, 7, 90, 900) + ] + for mean, variance in results[1:]: + self.assertAlmostEqual(mean / results[0][0], 1.0, places=10) + self.assertAlmostEqual(variance / results[0][1], 1.0, places=8) + + def test_an_empty_batch_changes_nothing(self): + accumulator = WelfordAccumulator() + accumulator.update(tf.constant(np.arange(10.0), dtype=tf.float64)) + before = ( + float(accumulator.n.numpy()), + float(accumulator.mean.numpy()), + float(accumulator.variance.numpy()), + ) + accumulator.update(tf.constant([], dtype=tf.float64)) + after = ( + float(accumulator.n.numpy()), + float(accumulator.mean.numpy()), + float(accumulator.variance.numpy()), + ) + self.assertEqual(before, after) + + def test_accumulators_do_not_share_state(self): + first = WelfordAccumulator() + first.update(tf.constant(np.zeros(100), dtype=tf.float64)) + second = WelfordAccumulator() + second.update(tf.constant(np.full(50, 7.0), dtype=tf.float64)) + self.assertEqual(float(first.n.numpy()), 100.0) + self.assertEqual(float(first.mean.numpy()), 0.0) + self.assertEqual(float(second.n.numpy()), 50.0) + self.assertAlmostEqual(float(second.mean.numpy()), 7.0, places=10) + + def test_merging_accumulators_equals_accumulating_the_whole(self): + """Grouped statistics are pooled group by group through `merge`. + + The grouped time series path replaced each group with `count` copies of + its own mean before combining, which keeps only the variance *between* + the group means. Two groups of spread 25 whose means happened to be + close reported 0.73 against a true 643. + """ + rng = np.random.default_rng(9) + parts = [ + rng.normal(10, 2, 300), + rng.normal(1000, 50, 700), + rng.normal(-5, 0.5, 120), + ] + whole = np.concatenate(parts) + + single = WelfordAccumulator() + single.update(tf.constant(whole, dtype=tf.float64)) + + pooled = WelfordAccumulator() + for part in parts: + group = WelfordAccumulator() + group.update(tf.constant(part, dtype=tf.float64)) + pooled.merge(group) + + self.assertAlmostEqual(float(pooled.n.numpy()), float(len(whole))) + self.assertAlmostEqual( + float(pooled.mean.numpy()), + float(single.mean.numpy()), + places=8, + ) + self.assertAlmostEqual( + float(pooled.variance.numpy()) / float(single.variance.numpy()), + 1.0, + places=9, + ) + self.assertAlmostEqual( + float(pooled.variance.numpy()) / float(np.var(whole, ddof=1)), + 1.0, + places=9, + ) + + def test_merging_an_empty_accumulator_changes_nothing(self): + accumulator = WelfordAccumulator() + accumulator.update(tf.constant(np.arange(20.0), dtype=tf.float64)) + before = ( + float(accumulator.n.numpy()), + float(accumulator.mean.numpy()), + float(accumulator.variance.numpy()), + ) + accumulator.merge(WelfordAccumulator()) + self.assertEqual( + ( + float(accumulator.n.numpy()), + float(accumulator.mean.numpy()), + float(accumulator.variance.numpy()), + ), + before, + ) + + +@pytest.mark.unit +class TestTheHigherMomentsAndExtremes(unittest.TestCase): + """Skewness, kurtosis, min and max, against the same data in one pass. + + The advisor reads all four; none were collected, so it saw skew 0 and + kurtosis 3 for every column and called them all normal. They are pooled the + same way as the mean and variance, so batching must not move them. + """ + + CASES = { + "normal": lambda rng: rng.normal(0.0, 1.0, 4000), + "right_skewed": lambda rng: rng.exponential(3.0, 4000), + "left_skewed": lambda rng: -rng.exponential(3.0, 4000), + "heavy_tailed": lambda rng: rng.standard_t(3, 4000), + "far_from_zero": lambda rng: rng.normal(1e6, 50.0, 4000), + } + + @staticmethod + def _reference(column: np.ndarray) -> dict: + centered = column - column.mean() + second = (centered**2).mean() + return { + "skewness": (centered**3).mean() / second**1.5, + "kurtosis": (centered**4).mean() / second**2, + "min": column.min(), + "max": column.max(), + } + + @staticmethod + def _accumulate(column: np.ndarray, batches: int) -> dict: + accumulator = WelfordAccumulator() + for chunk in np.array_split(column, batches): + accumulator.update(tf.constant(chunk)) + return { + "skewness": float(accumulator.skewness.numpy()), + "kurtosis": float(accumulator.kurtosis.numpy()), + "min": float(accumulator.smallest.numpy()), + "max": float(accumulator.largest.numpy()), + } + + def test_they_match_the_data_whatever_the_batch_size(self) -> None: + generator = np.random.default_rng(21) + for name, make in self.CASES.items(): + column = make(generator) + reference = self._reference(column) + for batches in (1, 37, 500): + with self.subTest(case=name, batches=batches): + got = self._accumulate(column, batches) + for key, expected in reference.items(): + self.assertAlmostEqual( + got[key] / max(abs(expected), 1e-9), + expected / max(abs(expected), 1e-9), + places=6, + msg=f"{name}/{key} at {batches} batches", + ) + + def test_the_shapes_are_told_apart(self) -> None: + """The numbers have to be different, not merely present.""" + generator = np.random.default_rng(21) + normal = self._accumulate(generator.normal(0.0, 1.0, 4000), 7) + skewed = self._accumulate(generator.exponential(3.0, 4000), 7) + self.assertLess(abs(normal["skewness"]), 0.2) + self.assertGreater(skewed["skewness"], 1.5) + self.assertLess(abs(normal["kurtosis"] - 3.0), 0.4) + self.assertGreater(skewed["kurtosis"], 6.0) + + def test_merging_gives_the_same_answer_as_one_pass(self) -> None: + column = np.random.default_rng(29).exponential(2.0, 3000) + whole = WelfordAccumulator() + whole.update(tf.constant(column)) + + head = WelfordAccumulator() + head.update(tf.constant(column[:1100])) + tail = WelfordAccumulator() + tail.update(tf.constant(column[1100:])) + head.merge(tail) + + for name in ("skewness", "kurtosis", "smallest", "largest"): + with self.subTest(statistic=name): + self.assertAlmostEqual( + float(getattr(head, name).numpy()), + float(getattr(whole, name).numpy()), + places=6, + ) + + def test_too_few_values_answer_with_the_neutral_defaults(self) -> None: + empty = WelfordAccumulator() + self.assertEqual(float(empty.skewness.numpy()), 0.0) + self.assertEqual(float(empty.kurtosis.numpy()), 3.0) + self.assertEqual(float(empty.smallest.numpy()), 0.0) + self.assertEqual(float(empty.largest.numpy()), 0.0) + + empty.update(tf.constant([], dtype=tf.float32)) + self.assertEqual(float(empty.n.numpy()), 0.0) + self.assertEqual(float(empty.smallest.numpy()), 0.0) + + one = WelfordAccumulator() + one.update(tf.constant([5.0])) + self.assertEqual(float(one.smallest.numpy()), 5.0) + self.assertEqual(float(one.largest.numpy()), 5.0) + self.assertEqual(float(one.skewness.numpy()), 0.0) + + def test_a_constant_column_has_no_shape_to_report(self) -> None: + constant = WelfordAccumulator() + constant.update(tf.constant(np.full(100, 42.0))) + self.assertEqual(float(constant.skewness.numpy()), 0.0) + self.assertEqual(float(constant.kurtosis.numpy()), 3.0) + self.assertEqual(float(constant.smallest.numpy()), 42.0) + self.assertEqual(float(constant.largest.numpy()), 42.0) diff --git a/test/test_text_vocabulary_matches_the_layer.py b/test/test_text_vocabulary_matches_the_layer.py new file mode 100644 index 0000000..643a2be --- /dev/null +++ b/test/test_text_vocabulary_matches_the_layer.py @@ -0,0 +1,172 @@ +"""The vocabulary collected from the data has to be the one the layer looks up. + +`TextVectorization` standardizes before it splits: by default it lowercases and +strips punctuation. The statistics pass lowercased and split on whitespace but +kept the punctuation, so a column of ordinary prose produced a vocabulary of +"product," and "it!" while the layer, at inference, looked up "product" and +"it". Every punctuated word therefore landed in the single out-of-vocabulary +slot. The output width was right, the counts summed correctly, and roughly half +the words in a normal English sentence were being thrown away. + +The same mismatch reached further: `ngrams` asks for word pairs the statistics +never recorded, and a custom `standardize` or `split` spells tokens differently +again. Those cases read the column and let the layer build its own vocabulary. +""" + +import tempfile +import unittest +from pathlib import Path + +import keras +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel +from kdp.features import TextFeature +from kdp.processor import OutputModeOptions +from kdp.stats import DatasetStatistics + +SENTENCES = [ + "Great product, loved it!", + "Terrible. Would not buy.", + "It's okay -- nothing special", + "Great value!", +] + + +def _frame(repeats: int = 40) -> pd.DataFrame: + return pd.DataFrame({"txt": SENTENCES * repeats}) + + +@pytest.mark.unit +class TestTextVocabularyMatchesTheLayer(unittest.TestCase): + """What the statistics collect, and what the layer expects, are one list.""" + + def setUp(self) -> None: + self.directory = Path(tempfile.mkdtemp()) + self.frame = _frame() + self.data = self.directory / "data.csv" + self.frame.to_csv(self.data, index=False) + + def test_the_statistics_vocabulary_is_what_keras_would_build(self) -> None: + statistics = DatasetStatistics( + path_data=str(self.data), + features_stats_path=str(self.directory / "stats.json"), + features_specs={"txt": FeatureType.TEXT}, + overwrite_stats=True, + ).main() + collected = sorted(statistics["text"]["txt"]["vocab"]) + + vectorizer = keras.layers.TextVectorization(output_mode="count") + vectorizer.adapt(tf.constant(self.frame["txt"].values)) + adapted = sorted( + str(word) + for word in vectorizer.get_vocabulary() + if word not in ("", "[UNK]") + ) + + self.assertEqual(collected, adapted) + + def _counts(self, **kwargs) -> np.ndarray: + feature = TextFeature( + name="txt", + feature_type=FeatureType.TEXT, + output_mode="count", + **kwargs, + ) + preprocessor = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / f"stats_{kwargs}.json"), + features_specs={"txt": feature}, + overwrite_stats=True, + output_mode=OutputModeOptions.DICT, + ) + preprocessor.build_preprocessor() + return np.asarray( + preprocessor.model( + {"txt": tf.constant(np.array(SENTENCES).reshape(-1, 1))}, + )["txt"], + ) + + def test_no_word_of_the_training_text_falls_out_of_vocabulary(self) -> None: + """Column 0 of a `count` output is the out-of-vocabulary bucket.""" + counts = self._counts() + self.assertEqual( + counts[:, 0].sum(), + 0.0, + f"words went missing: {counts[:, 0].tolist()}", + ) + + def test_every_word_is_accounted_for(self) -> None: + counts = self._counts() + for sentence, row in zip(SENTENCES, counts, strict=True): + # "--" is punctuation, and disappears under standardization. + expected = len([w for w in sentence.split() if w.strip("-")]) + self.assertEqual( + row.sum(), + expected, + f"{sentence!r} matched {row.sum()} of {expected} tokens", + ) + + +@pytest.mark.unit +class TestOptionsThatChangeWhatATokenIs(unittest.TestCase): + """`ngrams` has to reach the vocabulary, not just the tokenizer.""" + + def setUp(self) -> None: + self.directory = Path(tempfile.mkdtemp()) + generator = np.random.default_rng(13) + phrases = ["red car fast", "blue sky wide open", "green grass", "red red red"] + self.frame = pd.DataFrame({"txt": generator.choice(phrases, 150)}) + self.data = self.directory / "data.csv" + self.frame.to_csv(self.data, index=False) + self.phrases = phrases + + def _width_and_oov(self, **kwargs) -> tuple[int, float]: + feature = TextFeature( + name="txt", + feature_type=FeatureType.TEXT, + output_mode="multi_hot", + **kwargs, + ) + preprocessor = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / f"stats_{kwargs}.json"), + features_specs={"txt": feature}, + overwrite_stats=True, + output_mode=OutputModeOptions.DICT, + ) + preprocessor.build_preprocessor() + result = np.asarray( + preprocessor.model( + {"txt": tf.constant(np.array(self.phrases).reshape(-1, 1))}, + )["txt"], + ) + return int(result.shape[1]), float(result[:, 0].sum()) + + def test_ngrams_widen_the_vocabulary_instead_of_hitting_the_oov_slot(self) -> None: + unigram_width, _ = self._width_and_oov() + bigram_width, bigram_oov = self._width_and_oov(ngrams=2) + self.assertGreater( + bigram_width, + unigram_width, + "asking for bigrams produced the unigram vocabulary", + ) + self.assertEqual(bigram_oov, 0.0, "every bigram fell out of vocabulary") + + def test_the_width_matches_what_keras_builds_on_the_same_data(self) -> None: + for ngrams in (None, 2, 3): + with self.subTest(ngrams=ngrams): + width, _ = self._width_and_oov(ngrams=ngrams) + vectorizer = keras.layers.TextVectorization( + output_mode="multi_hot", + ngrams=ngrams, + ) + vectorizer.adapt(tf.constant(self.frame["txt"].values)) + self.assertEqual(width, len(vectorizer.get_vocabulary())) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_the_advisor_can_tell_distributions_apart.py b/test/test_the_advisor_can_tell_distributions_apart.py new file mode 100644 index 0000000..77c9d32 --- /dev/null +++ b/test/test_the_advisor_can_tell_distributions_apart.py @@ -0,0 +1,151 @@ +"""The advisor's advice has to come from the data, and has to be runnable. + +`ModelAdvisor` reads `skewness`, `kurtosis`, `min` and `max` off each numeric +feature's statistics, and the statistics carried none of them. Every column +therefore arrived with the neutral defaults -- skew 0, kurtosis 3 -- and came +back as "Normal distribution detected, standard normalization recommended" +whatever shape it actually had, while the rescaling factor derived from `min` +and `max` always worked out to exactly 1. + +The generated snippet had the matching problem: it built a `PreprocessingModel` +with no `path_data`, and every configuration the advisor recommends needs the +data, so pasting the advice raised. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from kdp.features import FeatureType +from kdp.model_advisor import recommend_model_configuration +from kdp.stats import DatasetStatistics + +ROWS = 4000 + + +def _statistics(directory: Path, frame: pd.DataFrame) -> dict: + data = directory / "data.csv" + frame.to_csv(data, index=False) + return DatasetStatistics( + path_data=str(data), + features_stats_path=str(directory / "stats.json"), + features_specs={ + name: FeatureType.STRING_CATEGORICAL + if frame[name].dtype == object + else FeatureType.FLOAT_NORMALIZED + for name in frame + }, + overwrite_stats=True, + ).main() + + +@pytest.mark.unit +class TestTheAdvisorCanTellDistributionsApart(unittest.TestCase): + """Three differently shaped columns must not get one answer.""" + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + generator = np.random.default_rng(19) + cls.frame = pd.DataFrame( + { + "gaussian": generator.normal(0.0, 1.0, ROWS), + "heavy_tailed": generator.exponential(5.0, ROWS), + "uniform": generator.uniform(0.0, 1.0, ROWS), + "label": generator.choice(["a", "b", "c"], ROWS), + }, + ) + cls.statistics = _statistics(cls.directory, cls.frame) + cls.recommendation = recommend_model_configuration(cls.statistics) + + def test_the_statistics_carry_the_numbers_the_advisor_reads(self) -> None: + numeric = self.statistics["numeric_stats"] + for name in ("gaussian", "heavy_tailed", "uniform"): + with self.subTest(feature=name): + for key in ("min", "max", "skewness", "kurtosis"): + self.assertIn(key, numeric[name]) + + def test_the_moments_match_the_data(self) -> None: + numeric = self.statistics["numeric_stats"] + for name in ("gaussian", "heavy_tailed", "uniform"): + column = self.frame[name].to_numpy() + centered = column - column.mean() + second = (centered**2).mean() + with self.subTest(feature=name): + self.assertAlmostEqual( + numeric[name]["skewness"], + (centered**3).mean() / second**1.5, + places=2, + ) + self.assertAlmostEqual( + numeric[name]["kurtosis"], + (centered**4).mean() / second**2, + places=2, + ) + self.assertAlmostEqual(numeric[name]["min"], column.min(), places=3) + self.assertAlmostEqual(numeric[name]["max"], column.max(), places=3) + + def test_each_column_gets_the_advice_its_shape_calls_for(self) -> None: + features = self.recommendation["features"] + self.assertEqual(features["gaussian"]["detected_distribution"], "normal") + self.assertEqual( + features["heavy_tailed"]["detected_distribution"], + "heavy_tailed", + ) + self.assertNotEqual( + features["uniform"]["detected_distribution"], + features["heavy_tailed"]["detected_distribution"], + ) + + def test_the_advice_is_not_the_same_sentence_for_every_column(self) -> None: + notes = { + name: tuple(self.recommendation["features"][name].get("notes", ())) + for name in ("gaussian", "heavy_tailed", "uniform") + } + self.assertEqual( + len(set(notes.values())), + 3, + f"three differently shaped columns got the same advice: {notes}", + ) + + +@pytest.mark.unit +class TestTheGeneratedSnippetRuns(unittest.TestCase): + """Advice you cannot paste is not advice.""" + + def test_the_snippet_builds_a_preprocessor(self) -> None: + directory = Path(tempfile.mkdtemp()) + generator = np.random.default_rng(23) + frame = pd.DataFrame( + { + "amount": generator.exponential(3.0, 500), + "score": generator.normal(0.0, 1.0, 500), + "label": generator.choice(["x", "y"], 500), + }, + ) + statistics = _statistics(directory, frame) + snippet = recommend_model_configuration(statistics)["code_snippet"] + + self.assertIn('path_data="your_data.csv"', snippet) + + runnable = snippet.replace( + 'path_data="your_data.csv"', + f'path_data="{directory / "data.csv"}"', + ).replace( + " features_specs=features,", + " features_specs=features,\n" + f' features_stats_path="{directory / "fresh.json"}",\n' + " overwrite_stats=True,", + ) + namespace: dict = {} + exec(compile(runnable, "", "exec"), namespace) # noqa: S102 + result = namespace["model"].build_preprocessor() + self.assertIsNotNone(result["model"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_the_column_order_is_stable.py b/test/test_the_column_order_is_stable.py new file mode 100644 index 0000000..edfa9f8 --- /dev/null +++ b/test/test_the_column_order_is_stable.py @@ -0,0 +1,137 @@ +"""Column N of the output has to be the same feature on every build. + +Features are preprocessed in parallel batches, and the concatenated output was +assembled by walking `processed_features` -- a dict keyed in whichever order the +workers happened to finish. Building the same configuration twice therefore laid +the columns out differently: five distinct layouts in six builds of the same six +features. + +Nothing raised, and the numbers were right for the layout each build chose. The +damage lands on anyone who trains on one build and serves from another: every +feature is read as a different one. Reloading a saved `.keras` file was always +safe, because the order is baked into its graph. +""" + +import tempfile +import unittest +from pathlib import Path + +import keras +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel + +ROWS = 200 +NUMERIC = [f"num{index}" for index in range(6)] + +SPECS = {name: FeatureType.FLOAT_NORMALIZED for name in NUMERIC} +SPECS["cat_string"] = FeatureType.STRING_CATEGORICAL +SPECS["cat_integer"] = FeatureType.INTEGER_CATEGORICAL + + +def _frame() -> pd.DataFrame: + generator = np.random.default_rng(67) + frame = pd.DataFrame({name: generator.normal(0.0, 1.0, ROWS) for name in NUMERIC}) + frame["cat_string"] = generator.choice(["a", "b", "c"], ROWS) + frame["cat_integer"] = generator.integers(0, 4, ROWS) + return frame + + +class _Built: + """One build of the preprocessor, with a way to locate each feature.""" + + def __init__(self, directory: Path, first: bool, **options) -> None: + keras.backend.clear_session() + self.preprocessor = PreprocessingModel( + path_data=str(directory / "data.csv"), + features_stats_path=str(directory / "stats.json"), + features_specs=SPECS, + overwrite_stats=first, + **options, + ) + self.preprocessor.build_preprocessor() + + @property + def names(self) -> tuple: + return tuple(self.preprocessor._concat_feature_names) + + def columns_moved_by(self, feature: str) -> tuple: + """Which output columns respond to a change in one numeric feature.""" + baseline = {name: tf.constant(np.zeros((3, 1)), np.float32) for name in NUMERIC} + baseline["cat_string"] = tf.constant(np.array(["a", "a", "a"]).reshape(-1, 1)) + baseline["cat_integer"] = tf.constant(np.zeros((3, 1), dtype=np.int32)) + before = np.asarray(self.preprocessor.model(baseline)) + + bumped = dict(baseline) + bumped[feature] = tf.constant(np.full((3, 1), 7.0), np.float32) + after = np.asarray(self.preprocessor.model(bumped)) + + return tuple(np.where(np.abs(after - before).max(axis=0) > 1e-6)[0].tolist()) + + +@pytest.mark.integration +class TestTheColumnOrderIsStable(unittest.TestCase): + """The same configuration has to produce the same layout every time.""" + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + _frame().to_csv(cls.directory / "data.csv", index=False) + + def test_the_reported_order_does_not_move(self) -> None: + orders = {_Built(self.directory, first=(run == 0)).names for run in range(5)} + self.assertEqual(len(orders), 1, f"the feature order moved: {orders}") + + def test_it_is_the_order_the_features_were_declared_in(self) -> None: + built = _Built(self.directory, first=True) + # Numeric features are concatenated before categorical ones, and within + # each block the declaration order holds. + self.assertEqual(built.names[: len(NUMERIC)], tuple(NUMERIC)) + self.assertEqual( + set(built.names[len(NUMERIC) :]), + {"cat_string", "cat_integer"}, + ) + + def test_each_feature_lands_in_the_same_columns_every_time(self) -> None: + layouts = set() + for run in range(4): + built = _Built(self.directory, first=(run == 0)) + layouts.add( + tuple((name, built.columns_moved_by(name)) for name in NUMERIC), + ) + self.assertEqual(len(layouts), 1, f"the column layout moved: {layouts}") + + def test_a_numeric_feature_sits_at_its_declared_index(self) -> None: + built = _Built(self.directory, first=True) + for index, name in enumerate(NUMERIC): + with self.subTest(feature=name): + self.assertEqual(built.columns_moved_by(name), (index,)) + + def test_the_mixture_of_experts_keeps_the_same_order(self) -> None: + orders = { + _Built( + self.directory, + first=(run == 0), + use_feature_moe=True, + feature_moe_num_experts=2, + feature_moe_expert_dim=4, + ).names + for run in range(4) + } + self.assertEqual(len(orders), 1, f"the feature order moved: {orders}") + + def test_a_cross_comes_after_the_declared_features(self) -> None: + built = _Built( + self.directory, + first=True, + feature_crosses=[("cat_string", "cat_integer", 8)], + ) + self.assertEqual(built.names[-1], "cat_string_x_cat_integer") + self.assertEqual(built.names[: len(NUMERIC)], tuple(NUMERIC)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_the_output_is_always_a_table.py b/test/test_the_output_is_always_a_table.py new file mode 100644 index 0000000..65a2fd6 --- /dev/null +++ b/test/test_the_output_is_always_a_table.py @@ -0,0 +1,155 @@ +"""Whatever the options, a preprocessor returns one row of numbers per input row. + +A transformer block adds a sequence axis to a 2-D input and hands it back. +The categorical placement flattened that away; `transfo_placement="all_features"` +did not, so that one configuration returned (rows, 1, width). A Dense head +bolted onto the preprocessor sees a different rank depending on a flag that has +nothing to do with the output shape, and nothing said so. + +Rank is the contract every downstream model depends on, so it is checked for +every model-level option rather than for the ones that happened to be tried. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp import FeatureType, PreprocessingModel + +ROWS = 80 + +SPECS = { + "num1": FeatureType.FLOAT_NORMALIZED, + "num2": FeatureType.FLOAT_RESCALED, + "cat1": FeatureType.STRING_CATEGORICAL, + "cat2": FeatureType.INTEGER_CATEGORICAL, +} + +# Every model-level switch that reshapes the concatenated block, and the +# combinations that stack two of them. +CONFIGURATIONS = { + "plain": {}, + "attention_all": { + "tabular_attention": True, + "tabular_attention_heads": 2, + "tabular_attention_dim": 16, + "tabular_attention_placement": "all_features", + }, + "attention_numeric": { + "tabular_attention": True, + "tabular_attention_heads": 2, + "tabular_attention_dim": 16, + "tabular_attention_placement": "numeric", + }, + "attention_categorical": { + "tabular_attention": True, + "tabular_attention_heads": 2, + "tabular_attention_dim": 16, + "tabular_attention_placement": "categorical", + }, + "attention_multi_resolution": { + "tabular_attention": True, + "tabular_attention_heads": 2, + "tabular_attention_dim": 16, + "tabular_attention_placement": "multi_resolution", + }, + "transformer_categorical": { + "transfo_nr_blocks": 2, + "transfo_nr_heads": 2, + "transfo_ff_units": 8, + "transfo_placement": "categorical", + }, + "transformer_all_features": { + "transfo_nr_blocks": 2, + "transfo_nr_heads": 2, + "transfo_ff_units": 8, + "transfo_placement": "all_features", + }, + "transformer_and_attention": { + "tabular_attention": True, + "tabular_attention_heads": 1, + "tabular_attention_dim": 12, + "tabular_attention_placement": "all_features", + "transfo_nr_blocks": 1, + "transfo_nr_heads": 2, + "transfo_ff_units": 8, + "transfo_placement": "all_features", + }, + "feature_selection": { + "feature_selection_placement": "all_features", + "feature_selection_units": 8, + }, + "feature_moe": {"use_feature_moe": True, "feature_moe_num_experts": 3}, + "global_numerical_embedding": { + "use_global_numerical_embedding": True, + "global_embedding_dim": 6, + }, + "feature_crosses": {"feature_crosses": [("cat1", "cat2", 8)]}, +} + + +def _frame() -> pd.DataFrame: + generator = np.random.default_rng(5) + return pd.DataFrame( + { + "num1": generator.normal(0.0, 1.0, ROWS), + "num2": generator.uniform(0.0, 10.0, ROWS), + "cat1": generator.choice(["a", "b", "c"], ROWS), + "cat2": generator.integers(0, 4, ROWS), + }, + ) + + +@pytest.mark.unit +class TestTheOutputIsAlwaysATable(unittest.TestCase): + """Rank 2, one row out per row in, for every option.""" + + @classmethod + def setUpClass(cls) -> None: + cls.directory = Path(tempfile.mkdtemp()) + cls.frame = _frame() + cls.data = cls.directory / "data.csv" + cls.frame.to_csv(cls.data, index=False) + + def _build_and_call(self, name: str, options: dict) -> np.ndarray: + preprocessor = PreprocessingModel( + path_data=str(self.data), + features_stats_path=str(self.directory / f"stats_{name}.json"), + features_specs=SPECS, + overwrite_stats=True, + **options, + ) + preprocessor.build_preprocessor() + return np.asarray( + preprocessor.model( + { + column: tf.constant(self.frame[column].values.reshape(-1, 1)) + for column in self.frame + }, + ), + ) + + def test_every_configuration_returns_rows_by_width(self) -> None: + wrong = {} + for name, options in CONFIGURATIONS.items(): + result = self._build_and_call(name, options) + if result.ndim != 2 or result.shape[0] != ROWS: + wrong[name] = result.shape + self.assertEqual(wrong, {}, f"not (rows, width): {wrong}") + + def test_every_configuration_returns_finite_numbers(self) -> None: + wrong = [ + name + for name, options in CONFIGURATIONS.items() + if not np.isfinite(self._build_and_call(name, options)).all() + ] + self.assertEqual(wrong, [], f"non-finite output: {wrong}") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_time_series_feature.py b/test/test_time_series_feature.py index 30c5b5c..b1870b1 100644 --- a/test/test_time_series_feature.py +++ b/test/test_time_series_feature.py @@ -387,9 +387,13 @@ def test_build_layers_for_a_calendar_feature(self): "wavelet_transform_config": {"levels": 2, "flatten_output": False}, } ], + # tsfresh summarizes a window, and a time series feature hands one + # column per row over, so it needs a config that widens the feature + # first -- exactly like the wavelet cases above. [ { "name": "sales", + "lag_config": {"lags": [1, 2, 3]}, "tsfresh_feature_config": {"features": ["mean", "std", "min"]}, } ], diff --git a/test/test_transforms_stay_in_their_domain.py b/test/test_transforms_stay_in_their_domain.py new file mode 100644 index 0000000..6901fc7 --- /dev/null +++ b/test/test_transforms_stay_in_their_domain.py @@ -0,0 +1,116 @@ +"""A transform outside its domain must return a number, not NaN or infinity. + +`log` is defined for positive values. Every neighbouring transform in the layer +guards its own domain -- `sqrt` clamps at zero, `box-cox` at epsilon -- but +`log` handed negatives straight to `log()` and returned NaN. A NaN in a +preprocessor output is not a local problem: it spreads through every layer that +touches it and through every gradient taken from it, without a message. + +`logit` clipped into `[epsilon, 1 - epsilon]`, which reads as correct and was +not: the default epsilon of 1e-10 is far below the float32 gap at 1.0, so +`1 - epsilon` rounded back to exactly 1.0, `1 - x` became 0, and any value at +or above 1 came out as +inf. +""" + +import unittest + +import numpy as np +import pytest +import tensorflow as tf + +from kdp.layers.distribution_transform_layer import DistributionTransformLayer + +# Values that sit outside the domain of at least one transform. +OUT_OF_DOMAIN = np.array( + [[-1e6], [-5.0], [-0.5], [0.0], [0.5], [1.0], [5.0], [1e6]], + dtype=np.float32, +) + +TRANSFORMS = [ + "log", + "sqrt", + "box-cox", + "yeo-johnson", + "arcsinh", + "cube-root", + "logit", + "quantile", + "robust-scale", + "min-max", +] + + +@pytest.mark.layers +class TestTransformsStayInTheirDomain(unittest.TestCase): + """No transform may answer with NaN or infinity.""" + + def test_every_transform_returns_finite_numbers(self) -> None: + offenders = {} + for name in TRANSFORMS: + result = np.asarray( + DistributionTransformLayer(transform_type=name)( + tf.constant(OUT_OF_DOMAIN), + ), + ) + if not np.isfinite(result).all(): + offenders[name] = result.reshape(-1).tolist() + self.assertEqual(offenders, {}, f"NaN or infinity in the output: {offenders}") + + def test_log_still_matches_its_definition_where_it_is_defined(self) -> None: + values = np.linspace(0.05, 5.0, 40, dtype=np.float32).reshape(-1, 1) + result = np.asarray( + DistributionTransformLayer(transform_type="log")(tf.constant(values)), + ) + np.testing.assert_allclose(result, np.log(values + 1e-10), atol=1e-4) + + def test_logit_still_matches_its_definition_inside_the_unit_interval(self) -> None: + values = np.linspace(0.05, 0.95, 40, dtype=np.float32).reshape(-1, 1) + result = np.asarray( + DistributionTransformLayer(transform_type="logit")(tf.constant(values)), + ) + np.testing.assert_allclose(result, np.log(values / (1 - values)), atol=5e-3) + + def test_logit_is_symmetric_at_both_ends(self) -> None: + """The clip has to bite on both sides, not only the low one.""" + result = np.asarray( + DistributionTransformLayer(transform_type="logit")( + tf.constant(np.array([[0.0], [1.0]], dtype=np.float32)), + ), + ).reshape(-1) + self.assertTrue(np.isfinite(result).all()) + np.testing.assert_allclose(result[0], -result[1], rtol=1e-5) + + def test_arcsinh_is_odd_and_matches_its_definition(self) -> None: + """`log(x + sqrt(x*x + 1))` lost the whole negative tail to float32. + + At x = -1e6 the square is 1e12, where float32 cannot hold the +1; its + root is exactly 1e6, the sum is exactly zero, and the logarithm is + -infinity. The positive side was unaffected, so the transform was quietly + one-sided. + """ + values = np.array( + [[-1e6], [-1e3], [-1.0], [0.0], [1.0], [1e3], [1e6]], + dtype=np.float32, + ) + result = np.asarray( + DistributionTransformLayer(transform_type="arcsinh")(tf.constant(values)), + ).reshape(-1) + np.testing.assert_allclose(result, np.arcsinh(values.reshape(-1)), rtol=1e-5) + np.testing.assert_allclose(result, -result[::-1], rtol=1e-5) + + def test_a_negative_column_survives_the_log_transform_in_a_model(self) -> None: + """The same thing, through the layer as a model would call it.""" + column = np.linspace(-50.0, 50.0, 100, dtype=np.float32).reshape(-1, 1) + result = np.asarray( + DistributionTransformLayer(transform_type="log")(tf.constant(column)), + ) + self.assertTrue(np.isfinite(result).all()) + self.assertGreater( + len(np.unique(result)), + 10, + "the positive half of the column was flattened too", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_tsfresh_needs_a_window.py b/test/test_tsfresh_needs_a_window.py new file mode 100644 index 0000000..ee445d1 --- /dev/null +++ b/test/test_tsfresh_needs_a_window.py @@ -0,0 +1,118 @@ +"""Statistics over a window of one are not statistics. + +A `TimeSeriesFeature` hands one column per row to the layers it builds, and +`TSFreshFeatureLayer` clamps `window_size` to the number of steps it is given. +Over a single step the mean, the minimum and the maximum are all the value +itself and the standard deviation is zero, so a config asking for four +statistics returned the input column three times plus a column of zeros. The +width matched what `get_output_dim()` declared, the values were finite, and +none of it carried anything. + +The wavelet already refused the same shape of input. This checks that tsfresh +does too, and that once it has a window its numbers are the ones pandas gets. +""" + +import unittest + +import numpy as np +import pandas as pd +import pytest +import tensorflow as tf + +from kdp.features import TimeSeriesFeature +from kdp.layers.time_series.tsfresh_feature_layer import TSFreshFeatureLayer + +ROWS = 96 +SERIES = np.sin(np.arange(ROWS) / 6.0) * 10 + np.arange(ROWS) * 0.3 + 50 + + +def _through(feature: TimeSeriesFeature) -> np.ndarray: + data = tf.constant(SERIES.reshape(-1, 1), dtype=tf.float32) + for layer in feature.build_layers(): + data = layer(data) + return np.asarray(data) + + +@pytest.mark.time_series +class TestTsfreshNeedsAWindow(unittest.TestCase): + """One step in, nothing to summarize.""" + + def test_a_single_step_is_refused(self) -> None: + with self.assertRaises(ValueError) as raised: + TSFreshFeatureLayer(features=["mean", "std"])( + tf.constant(np.arange(10.0).reshape(-1, 1), dtype=tf.float32), + ) + message = str(raised.exception) + self.assertIn("two time steps", message) + self.assertIn("lag_config", message) + + def test_a_time_series_feature_without_a_window_is_refused(self) -> None: + feature = TimeSeriesFeature( + name="sales", + sort_by="order", + tsfresh_feature_config={ + "features": ["mean", "std", "min", "max"], + "window_size": 8, + }, + ) + with self.assertRaises(ValueError): + _through(feature) + + def test_with_a_window_the_statistics_are_the_ones_pandas_gets(self) -> None: + window = 8 + feature = TimeSeriesFeature( + name="sales", + sort_by="order", + lag_config={ + "lags": list(range(1, window)), + "drop_na": False, + "fill_value": 0.0, + }, + tsfresh_feature_config={ + "features": ["mean", "std", "min", "max"], + "window_size": window, + }, + ) + produced = _through(feature) + self.assertEqual(produced.shape, (ROWS, 4)) + + series = pd.Series(SERIES) + rolling = series.rolling(window) + expected = { + 0: rolling.mean(), + 1: rolling.std(ddof=0), + 2: rolling.min(), + 3: rolling.max(), + } + # Only the rows with a full window: earlier ones sit on the lag padding. + for column, reference in expected.items(): + with self.subTest(statistic=column): + np.testing.assert_allclose( + produced[window - 1 :, column], + reference.to_numpy()[window - 1 :], + atol=1e-2, + ) + + def test_the_statistics_are_not_four_copies_of_the_input(self) -> None: + """What the degenerate window produced, stated as a check.""" + feature = TimeSeriesFeature( + name="sales", + sort_by="order", + lag_config={"lags": [1, 2, 3, 4, 5], "drop_na": False, "fill_value": 0.0}, + tsfresh_feature_config={ + "features": ["mean", "std", "min", "max"], + "window_size": 6, + }, + ) + produced = _through(feature) + self.assertGreater( + produced[:, 1].std(), + 0.0, + "the standard deviation column is constant", + ) + distinct = {tuple(np.round(produced[:, i], 4)) for i in range(4)} + self.assertEqual(len(distinct), 4, "two statistics produced the same column") + + +if __name__ == "__main__": + unittest.main()