Variable Transformation Directory
This page is a standalone catalogue of variable and feature transformation techniques, kept separate from the
model-training pages that use them. Summary Statistics already carries the original guide’s own
two log-transform procedures (Procedures 12–13, condensed from a single paragraph) as part of its wider
summary-statistics sweep; this page is the fuller reference for that technique and every other rescaling, binning,
encoding and feature-construction technique an analyst reaches for in practice. Every transformation here also produces
the same packed double[] table shape that
Data Frames vs the Packed Array describes as the product’s one data layer — a transform’s
job is to rewrite the numbers in that table, never to change its shape contract.
Each transformation below follows the same shape as the rest of this guide: a real, re-run R snippet first (base R plus
already-installed packages; no fabricated console output), then the exact
AdvancedAnalytics tool that does the equivalent job in the product. The tools live in
Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling, split across ScalingTools.cs,
BinningTools.cs, EncodingTools.cs and FeatureEngineeringTools.cs. A “Fit” tool learns parameters from training
rows and returns a transformed table plus those parameters; the matching “Apply” tool reuses the parameters on new rows
so training and live scoring see identical arithmetic, which plain R scripting has to reproduce by hand every time.
Log Transform and Its Reverse
Money and count variables are usually right-skewed: a handful of very large values stretch the mean and standard
deviation away from where most of the data sits. The natural-log transform compresses that long right tail, and its
reverse (exp) recovers the original units for reporting.
Procedure 1: Create a Log Transformation
set.seed(42)
Amount <- round(rlnorm(10, meanlog = 4, sdlog = 1), 2)
Amount
LogAmount <- log(Amount)
round(LogAmount, 4)
Console output:
[1] 215.07 31.04 78.50 102.81 81.80 49.10 247.53 49.67 410.93 51.28
[1] 5.3710 3.4353 4.3631 4.6329 4.4043 3.8939 5.5115 3.9054 6.0184 3.9373
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.ScalingTools
/ tool "ApplyColumnFunction" with function: NaturalLog — applies ln(x + offset) to every cell of a column,
returning the transformed table and how many cells were undefined (non-positive after the offset) rather than silently
producing NaN in the console the way plain R does.
Procedure 2: Reverse a Log Transformation
RestoredAmount <- exp(LogAmount)
round(RestoredAmount, 2)
Console output:
[1] 215.07 31.04 78.50 102.81 81.80 49.10 247.53 49.67 410.93 51.28
Tool equivalent: none as a dedicated inverse — ApplyColumnFunction has no inverse counterpart of its
own, because a plain log/exp pair carries no fitted parameters to invert. Where the platform does track parameters (a
power transform’s lambda, a scaler’s center and scale) it gives an explicit inverse tool instead, covered in the next
two sections.
The log transform itself has no single-paper origin: it is the best-known member of Tukey’s “ladder of powers” (Tukey, Exploratory Data Analysis, Addison-Wesley, 1977), the ordered family of power transforms (…, 1/x, log x, sqrt x, x, x², …) an analyst climbs by eye. Box-Cox and Yeo-Johnson, below, select a member of that same family automatically by likelihood rather than by eye.
Power Transforms: Square Root, Box-Cox and Yeo-Johnson
A single fixed function such as log or sqrt is a guess at how much a column needs to be pulled in. Box-Cox and
Yeo-Johnson instead search for the power (lambda) that makes a column look as close to normal as possible, by maximising
a normal log-likelihood over lambda — the same criterion the product’s
FitPowerTransformer uses internally. Box-Cox needs strictly positive data; Yeo-Johnson extends the same idea to zero
and negative values by splitting the formula at zero.
Box-Cox is Box, G. E. P. and Cox, D. R. (1964), “An Analysis of Transformations,” Journal of the Royal Statistical Society, Series B, 26 (2): 211–252 — y (λ) = (x^λ − 1)/λ for λ≠0, ln x for λ=0, restricted to x>0, with λ chosen to maximise a normal log-likelihood. Yeo-Johnson is Yeo, I.-K. and Johnson, R. A. (2000), “A New Family of Power Transformations to Improve Normality or Symmetry,” Biometrika, 87 (4): 954–959 — the signed extension that mirrors the same idea onto x+1 for x≥0 and onto −x+1 (with exponent 2−λ) for x<0, so it accepts the zero and negative values Box-Cox rejects.
Procedure 3: Square Root Transform
set.seed(42)
Amount <- round(rlnorm(200, meanlog = 4, sdlog = 1), 2)
SqrtAmount <- sqrt(Amount)
round(head(SqrtAmount), 4)
Console output:
[1] 14.6653 5.5714 8.8600 10.1395 9.0443 7.0071
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.ScalingTools
/ tool "ApplyColumnFunction" with function: Sqrt (or SignedSqrt when the column can be negative, computing
sign(x) * sqrt(|x|)).
Procedure 4: Box-Cox Power Transform via Maximum Likelihood
This reproduces the product’s own fitting rule by hand: search a grid of lambda, transform with
(x^lambda - 1) / lambda (or log(x) at lambda 0), and keep the lambda with the highest normal log-likelihood.
boxcox_transform <- function(x, lambda) {
if (abs(lambda) < 1e-12) log(x) else (x^lambda - 1) / lambda
}
boxcox_loglik <- function(x, lambda) {
y <- boxcox_transform(x, lambda)
n <- length(y)
var_y <- sum((y - mean(y))^2) / n
jacobian <- sum(log(x))
-n / 2 * log(var_y) + (lambda - 1) * jacobian
}
lambda_grid <- seq(-2, 2, by = 0.01)
loglik <- sapply(lambda_grid, function(l) boxcox_loglik(Amount, l))
best_lambda <- lambda_grid[which.max(loglik)]
round(best_lambda, 2)
BoxCoxAmount <- boxcox_transform(Amount, best_lambda)
round(head(BoxCoxAmount), 4)
round(c(mean = mean(Amount), skew_before = mean((Amount - mean(Amount))^3) / sd(Amount)^3,
skew_after = mean((BoxCoxAmount - mean(BoxCoxAmount))^3) / sd(BoxCoxAmount)^3), 4)
Console output:
[1] 0.05
[1] 6.1613 3.7479 4.8756 5.2134 4.9269 4.2988
mean skew_before skew_after
83.6283 3.5226 0.0110
The fitted lambda of 0.05 is close to 0, so this column is behaving almost exactly like a log transform — skewness drops
from 3.52 to essentially 0. A real MASS::boxcox likelihood plot would sharpen the same picture but is not required
here: this grid search already reproduces the maximisation the product performs with
Accord.Math.Optimization.BrentSearch.
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.ScalingTools
/ tools "FitPowerTransformer" (learns one lambda per column by maximising the same normal log-likelihood,
method: BoxCox) and "ApplyPowerTransformer" (applies fixed lambdas to new rows using the identical formula).
Procedure 5: Yeo-Johnson Power Transform for Signed Data
Box-Cox refuses a column with zeros or negative values (a daily profit-and-loss delta, for example). Yeo-Johnson keeps the same maximum-likelihood idea but applies one formula to the non-negative side of a column and a mirrored one to the negative side.
set.seed(42)
Delta <- round(rt(200, df = 3) * 50, 2)
round(head(Delta), 2)
yeojohnson_transform <- function(x, lambda) {
pos <- x >= 0
y <- numeric(length(x))
y[pos] <- if (abs(lambda) < 1e-12) log(x[pos] + 1) else ((x[pos] + 1)^lambda - 1) / lambda
neg <- !pos
y[neg] <- if (abs(lambda - 2) < 1e-12) -log(-x[neg] + 1) else -(((-x[neg] + 1)^(2 - lambda) - 1) / (2 - lambda))
y
}
yeojohnson_loglik <- function(x, lambda) {
y <- yeojohnson_transform(x, lambda)
n <- length(y)
var_y <- sum((y - mean(y))^2) / n
jacobian <- sum(sign(x) * log(1 + abs(x)))
-n / 2 * log(var_y) + (lambda - 1) * jacobian
}
lambda_grid <- seq(-2, 4, by = 0.01)
loglik <- sapply(lambda_grid, function(l) yeojohnson_loglik(Delta, l))
best_lambda <- lambda_grid[which.max(loglik)]
round(best_lambda, 2)
YeoJohnsonDelta <- yeojohnson_transform(Delta, best_lambda)
round(head(YeoJohnsonDelta), 4)
Console output:
[1] 116.98 6.55 -3.70 -2.89 -2.32 458.25
[1] 0.91
[1] 83.2937 5.8177 -4.0389 -3.1155 -2.4758 289.5866
Tool equivalent: the same
Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.ScalingTools tools,
"FitPowerTransformer" and "ApplyPowerTransformer", with method: YeoJohnson — the default method for both tools,
precisely because most real columns are not guaranteed positive.
Code audit: FitPowerTransformer genuinely performs the maximum-likelihood search the textbook describes rather
than applying a fixed lambda. LogLikelihood
(ScalingTools.cs:664-680) computes the same profile log-likelihood Box and Cox derive — -n/2 *
log(variance) plus (lambda - 1) times a Jacobian term, Σ log(x) for Box-Cox and Σ sign(x) *
log(1 + |x|) for Yeo-Johnson, matching each paper’s own Jacobian — and the fit loop (ScalingTools.cs:350-353) hands
that function to Accord.Math.Optimization.BrentSearch.Maximize over a configurable search range (default [-5, 5],
ScalingTools.cs:317), so the lambda returned is the one that actually maximises the likelihood on the supplied column,
not a hand-picked constant. PowerValue
(ScalingTools.cs:640-661) implements both formulas exactly, including the Yeo-Johnson sign-dependent split at x=0, and
Box-Cox rejects any non-positive value up front (ScalingTools.cs:343-348) rather than silently producing NaN mid-fit.
Standardisation and Range Normalisation
Standardisation (z-score) and range normalisation (min-max) both rescale a column to a common footing, but answer different questions: a z-score says how many standard deviations a value sits from the mean; a min-max value says where a value sits between the observed minimum and maximum, in the same 0–1 units whatever the column’s original scale.
Procedure 6: Z-Score Standardisation
set.seed(42)
Amount <- round(rlnorm(10, meanlog = 4, sdlog = 1), 2)
Amount
ZAmount <- (Amount - mean(Amount)) / sd(Amount)
round(ZAmount, 4)
Console output:
[1] 215.07 31.04 78.50 102.81 81.80 49.10 247.53 49.67 410.93 51.28
[1] 0.6810 -0.8236 -0.4355 -0.2368 -0.4086 -0.6759 0.9464 -0.6713 2.2823
[10] -0.6581
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.ScalingTools
/ tools "FitTableScaler" with method: Standard (learns the mean and sample standard deviation per column, returning
them as Centers/Scales), "ApplyTableScaler" (applies fixed centers/scales to new rows so validation and live data
are scaled with training statistics only, avoiding leakage) and
"InvertTableScaler" (recovers original units from a scaled value, center and scale, for reporting model outputs or
cluster centres). The same tool also offers RobustMad (median and scaled median absolute deviation) and RobustIqr
(median and interquartile range) for columns where outliers should not be allowed to set the scale.
Procedure 7: Min-Max Range Normalisation
MinMaxAmount <- (Amount - min(Amount)) / (max(Amount) - min(Amount))
round(MinMaxAmount, 4)
Console output:
[1] 0.4844 0.0000 0.1249 0.1889 0.1336 0.0475 0.5699 0.0490 1.0000 0.0533
Tool equivalent: the same FitTableScaler / ApplyTableScaler / InvertTableScaler
family with method: MinMax, which learns the minimum as Centers and the range (max - min) as
Scales so (value - center) / scale lands every finite training value inside 0..1.
Both formulas are classical and carry no single citation: z-score standardisation is z = (x - mean) /
sd, using whichever standard deviation the implementation chooses (population or sample); min-max normalisation is
x' = (x - min) / (max - min), placing every training value in 0..1.
Code audit: FitTableScaler’s Standard branch calls Standardisation.Fit
(Jube.Analytics/Standardisation.cs:29-43), which computes the mean and the sample standard deviation
(/(finite.Length - 1)), matching R’s sd() rather than a population (n-divisor) figure. Division by zero is guarded:
Scale() (ScalingTools.cs:575-583) checks scales[j] == 0 || !double.IsFinite(scales[j])
and returns 0 for that cell instead of NaN or Infinity, and InvertTableScaler shares the same guarded centers/scales.
The file’s other two scale rules reuse the same audited helpers: RobustMad
(ScalingTools.cs:125-129) calls RobustScaler.Fit (Jube.Analytics/Scalers.cs:87-93), which scales the median
absolute deviation by the constant 1.482602218505602 — the standard consistency factor 1/Φ⁻¹ (0.75) that makes MAD a
consistent estimator of the normal standard deviation — matching the
“1.4826” the tool’s own description states; RobustIqr (ScalingTools.cs:130-134) uses the same
DataPreparationGuard.Quantile interpolation audited under Quantile Transformation below.
Quantile Transformation
A power transform bends a column with one formula. A quantile transform instead learns the column’s own empirical distribution and maps each value to its rank within it, which is far more robust to heavy tails and to outliers that a power transform would still leave stretched out — only the ordering of values matters, not their magnitude.
Procedure 8: Quantile Transformation to Uniform and Normal
set.seed(42)
Amount <- round(rlnorm(200, meanlog = 4, sdlog = 1), 2)
EmpiricalCdf <- ecdf(Amount)
UniformAmount <- EmpiricalCdf(Amount)
round(head(UniformAmount), 4)
NormalAmount <- qnorm(pmin(pmax(UniformAmount, 1e-7), 1 - 1e-7))
round(head(NormalAmount), 4)
round(c(before_skew = mean((Amount - mean(Amount))^3) / sd(Amount)^3,
after_skew = mean((NormalAmount - mean(NormalAmount))^3) / sd(NormalAmount)^3), 4)
Console output:
[1] 0.920 0.265 0.655 0.750 0.670 0.460
[1] 1.4051 -0.6280 0.3989 0.6745 0.4399 -0.1004
before_skew after_skew
3.5226 0.5430
ecdf() here plays the same role as the product’s stored reference quantiles: both convert a value into
“the fraction of training rows at or below this,” clamped away from exactly 0 or 1 before it is pushed through qnorm
/the standard-normal inverse.
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.ScalingTools
/ tools "FitQuantileTransformer" (stores up to quantileCount reference quantiles per column instead of the full
empirical CDF, output Uniform or Normal) and "ApplyQuantileTransformer" (maps new rows onto those stored
quantiles; values beyond the training range clamp to the ends rather than extrapolate).
This is the probability integral transform, a standard result in distribution theory rather than a named invention: if X has continuous CDF F, then F (X) is distributed Uniform (0,1); pushing that uniform value through the standard normal quantile function Φ⁻¹ gives the Normal output option used above. It underlies rank-based nonparametric statistics generally and has no single origin paper.
Code audit: DataPreparationGuard.Quantile (DataPreparationGuard.cs:236-242) — used both to build
FitQuantileTransformer’s reference quantiles and, separately, by DiscretiseEqualFrequency’s cut points below —
interpolates with position = q * (n - 1), lower + (position - lower) * (upper - lower), which is exactly R’s
stats::quantile() default (type = 7) interpolation rule, not one of R’s other eight interpolation types.
ApplyQuantileTransformer’s inverse mapping, EmpiricalPosition
(ScalingTools.cs:603-638), diverges from a plain single-direction lookup (the kind numpy.interp does):
it scans the reference array from both ends and averages the two resulting rank estimates before dividing by n-1, which
symmetrises the result when stored reference quantiles contain duplicate values (ties)
rather than always taking the first match. This is a deliberate tie-handling choice, not a bug, but it is worth knowing
it is not identical to scikit-learn’s QuantileTransformer.inverse_transform.
Outlier Winsorisation and Clipping
Winsorising caps extreme values at a threshold instead of deleting the rows they belong to, which keeps the row count and every other column’s data intact while stopping one column’s extremes from dominating a distance-based or linear model.
Procedure 9: Winsorise Outliers at the 1st and 99th Percentiles
set.seed(42)
Amount <- round(rlnorm(200, meanlog = 4, sdlog = 1), 2)
Amount[3] <- 50000
Amount[57] <- 48000
Bounds <- quantile(Amount, probs = c(0.01, 0.99))
Bounds
ClippedAmount <- pmin(pmax(Amount, Bounds[1]), Bounds[2])
round(range(Amount), 2)
round(range(ClippedAmount), 2)
sum(Amount != ClippedAmount)
Console output:
1% 99%
4.7507 1285.8006
[1] 2.74 50000.00
[1] 4.75 1285.80
[1] 4
Two planted extreme values (50000 and 48000) are pulled in to the 99th-percentile bound, and the four cells that ended up clipped (both planted outliers plus the two natural values already past the 1st/99th percentile) are reported by count.
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.ScalingTools
/ tools "FitOutlierClipper" (learns lower/upper bounds per column; method: Quantiles reproduces this snippet
exactly, with IqrFences, StandardDeviations and MedianAbsoluteDeviations as alternative bound rules) and
"ApplyOutlierClipper" (clips new rows to fixed bounds and reports how many cells were clipped low and high per column,
rather than a single combined count).
The name comes from Charles Winsor, via John Tukey, who popularised winsorising and clipping as part of the exploratory
data analysis tradition (Tukey, Exploratory Data Analysis, Addison-Wesley, 1977); the 1.5×IQR “fence” IqrFences
defaults to below is Tukey’s own rule of thumb from that same tradition.
Code audit: FitOutlierClipper (ScalingTools.cs:444-521) implements all four bound rules exactly as described:
Quantiles (ScalingTools.cs:497-499) matches this procedure’s quantile() call cell for cell; IqrFences
(ScalingTools.cs:501-506) computes Q1 - k*IQR / Q3 + k*IQR with a default
multiplier of 1.5, i.e. Tukey’s fences; StandardDeviations (ScalingTools.cs:507-511) and
MedianAbsoluteDeviations (ScalingTools.cs:512-516) reuse the same Standardisation.Fit /
RobustScaler.Fit helpers audited under Standardisation above, so their scale estimates are the same sample-SD and
1.4826×MAD figures. Clip() (ScalingTools.cs:688-718) is a plain bound-and-count clip with no numerical surprises.
Discretisation and Binning
Discretising turns a continuous column into a small number of ordered categories. This trades away resolution for two things a raw continuous value cannot give a downstream model or a business rule: categories a rule can name directly (“bin 3, $5,000–$10,000”) and, when a target is supplied, a weight of evidence and information value per bin for scorecard-style modelling.
Procedure 10: Equal-Width and Custom-Edge Binning with cut ()
set.seed(42)
Amount <- round(rlnorm(200, meanlog = 4, sdlog = 1), 2)
EqualWidthBins <- cut(Amount, breaks = 5)
table(EqualWidthBins)
RegulatoryBins <- cut(Amount, breaks = c(-Inf, 1000, 5000, 10000, Inf),
labels = c("under1000", "1000to5000", "5000to10000", "over10000"))
table(RegulatoryBins)
Console output:
EqualWidthBins
(1.93,165] (165,327] (327,489] (489,652] (652,815]
173 21 4 1 1
RegulatoryBins
under1000 1000to5000 5000to10000 over10000
200 0 0 0
The equal-width result illustrates exactly the weakness the product’s own tool description warns about: with this skewed a column, 173 of 200 rows land in the first of five equal-width bins, which is nearly useless for separating cases. The regulatory-band cut points (1000/5000/10000) show custom edges, though this particular simulated column never reaches them.
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.BinningTools
/ tools "DiscretiseEqualWidth" (equal-width, matching the first cut() call and its own documented weakness on skewed
data — "DiscretiseEqualFrequency" cuts at evenly spaced quantiles instead, the usual fix), "DiscretiseCustomEdges"
(fixed cut points, matching the regulatory-band cut() call) and
"ApplyBinEdges" (assigns new rows to bins from any of the Discretise* tools’ edges, so live data is binned
identically to training data). Passing 0/1 labels to any Discretise* tool additionally returns each bin’s event rate,
weight of evidence and the column’s total information value — R’s cut() and
table() have no equivalent of that in one call.
Binning a continuous variable this way is surveyed in Dougherty, J., Kohavi, R. and Sahami, M. (1995), “Supervised and Unsupervised Discretization of Continuous Features,” Proceedings of the 12th International Conference on Machine Learning (ICML) — which frames equal-width and equal-frequency binning as the two basic unsupervised (target-blind) strategies demonstrated above, against which supervised methods such as the one below are compared.
Code audit: DiscretiseEqualFrequency (BinningTools.cs:61-80) places its cut points with the same
DataPreparationGuard.Quantile type-7 interpolation audited under Quantile Transformation above, then deduplicates
repeated cut points caused by ties before building bins.
Procedure 11: K-Means Binning
Where equal-width or equal-frequency bins ignore how a column actually clumps, k-means binning clusters the values into
k groups and cuts halfway between neighbouring cluster centres — closer to how the data is actually shaped. kmeans()
is a base R function from the stats package (always loaded), not from
cluster; cluster supplies pam/clara but no kmeans() of its own.
set.seed(42)
Amount <- round(rlnorm(200, meanlog = 4, sdlog = 1), 2)
KmeansFit <- kmeans(Amount, centers = 4, iter.max = 200, nstart = 5)
SortedCentres <- sort(KmeansFit$centers[, 1])
round(SortedCentres, 2)
Edges <- (head(SortedCentres, -1) + tail(SortedCentres, -1)) / 2
round(Edges, 2)
KMeansBins <- cut(Amount, breaks = c(-Inf, Edges, Inf), labels = FALSE)
table(KMeansBins)
Console output:
2 1 4 3
35.97 109.73 246.76 675.64
2 1 4
72.85 178.25 461.20
KMeansBins
1 2 3 4
127 49 22 2
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.BinningTools
/ tool "DiscretiseKMeans" — clusters a column with Accord.MachineLearning.Clustering.KMeans, seeding cluster centres
from evenly spaced quantiles rather than the random restarts nstart uses here, which makes the product’s result
deterministic across runs on the same data; the edges are cut halfway between sorted centres exactly as in this snippet.
The same file’s "DiscretiseSupervised" tool is a fourth option worth naming here even without its own R snippet: it
places cut points using a one-variable decision tree against a 0/1 target, which is the closest thing to “optimal
binning for a scorecard” in the family.
That target-aware alternative belongs to the family Fayyad, U. M. and Irani, K. B. (1993) established in “Multi-Interval Discretization of Continuous-Valued Attributes for Classification Learning,” Proceedings of IJCAI-93 — recursively splitting at the value that most reduces class entropy, stopped by a minimum-description-length criterion.
Code audit: DiscretiseSupervised (BinningTools.cs:138-213) does perform genuine entropy-based splitting, but via
Accord.MachineLearning.DecisionTrees.Learning.C45Learning
(BinningTools.cs:177-182) — Quinlan’s C4.5 induction algorithm, which splits on gain ratio (an entropy-derived
criterion) rather than the criterion Fayyad and Irani’s own paper defines. More importantly, the stopping rule is not
Fayyad-Irani’s minimum-description-length test: it is a fixed
MaxHeight = ceil(log2(maximumBins)) and a MinimumLeafSize derived from minimumBinFraction
(BinningTools.cs:176-182). Cut points are then read off the tree breadth-first and only the first
maximumBins - 1 kept (BinningTools.cs:186-210), which approximates “the most informative splits first” because C4.5
chooses its highest-gain-ratio split at the root — but this is a heuristic truncation by hyperparameter, not
Fayyad-Irani’s own recursive MDL stopping condition. In short: genuine entropy-based partitioning, using a different
tree-induction algorithm and a simpler, hyperparameter-driven stopping rule than the paper the technique is usually
named after.
Categorical Encoding
Most models cannot consume text categories directly. Label encoding gives every category an arbitrary integer, useful for tree models but wrong for anything that reads magnitude as meaning. One-hot encoding avoids that by giving every category its own 0/1 column. Neither uses a target; target-mean encoding, weight-of-evidence encoding and frequency encoding all fold in extra information (an outcome, or how common a category is) instead of just an arbitrary or expanding representation.
Procedure 12: Label Encoding
set.seed(42)
MerchantCategory <- sample(c("grocery", "electronics", "travel", "jewellery"),
size = 20, replace = TRUE, prob = c(0.4, 0.3, 0.2, 0.1))
IsFraud <- rbinom(20, 1, prob = ifelse(MerchantCategory == "jewellery", 0.6,
ifelse(MerchantCategory == "travel", 0.3, 0.05)))
MerchantFactor <- factor(MerchantCategory)
LabelEncoded <- as.integer(MerchantFactor) - 1
data.frame(MerchantCategory, LabelEncoded)
levels(MerchantFactor)
Console output:
MerchantCategory LabelEncoded
1 jewellery 2
2 jewellery 2
3 grocery 1
4 travel 3
5 electronics 0
6 electronics 0
7 travel 3
8 grocery 1
9 electronics 0
10 travel 3
11 electronics 0
12 travel 3
13 jewellery 2
14 grocery 1
15 electronics 0
16 jewellery 2
17 jewellery 2
18 grocery 1
19 electronics 0
20 electronics 0
[1] "electronics" "grocery" "jewellery" "travel"
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.EncodingTools
/ tools "FitLabelEncoder" (learns a category list per column — Sorted matches R’s alphabetical
factor() ordering used above, with FirstAppearance and MostFrequentFirst as alternatives — and returns the coded
table) and "ApplyLabelEncoder" (codes new rows from a fixed category list, or from a hand-supplied list in rank order
for a genuinely ordinal category such as Low/Medium/High; anything not found gets a configurable unknownCode, unlike
R’s factor(), which would turn an unseen level into
NA).
Procedure 13: One-Hot Encoding
OneHot <- model.matrix(~ MerchantFactor - 1)
colnames(OneHot) <- levels(MerchantFactor)
head(OneHot, 8)
Console output:
electronics grocery jewellery travel
1 0 0 1 0
2 0 0 1 0
3 0 1 0 0
4 0 0 0 1
5 1 0 0 0
6 1 0 0 0
7 0 0 0 1
8 0 1 0 0
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.EncodingTools
/ tools "FitOneHotEncoder" (expands each column into one 0/1 column per category, named
<column>=<category>; dropFirst mirrors dropping the reference level to avoid the dummy-variable trap, and
maximumCategoriesPerColumn guards against a high-cardinality column silently exploding the table’s width the way
model.matrix would) and "ApplyOneHotEncoder" (encodes new rows from a fixed category list; an unseen category
becomes all zeros rather than erroring).
Procedure 14: Target-Mean Encoding
TargetMeanTable <- aggregate(IsFraud, by = list(MerchantCategory = MerchantCategory), FUN = mean)
names(TargetMeanTable)[2] <- "RawEventRate"
OverallRate <- mean(IsFraud)
CountTable <- table(MerchantCategory)
TargetMeanTable$Count <- as.integer(CountTable[TargetMeanTable$MerchantCategory])
Smoothing <- 10
TargetMeanTable$SmoothedEncoding <- (TargetMeanTable$Count * TargetMeanTable$RawEventRate +
Smoothing * OverallRate) / (TargetMeanTable$Count + Smoothing)
TargetMeanTable
Console output:
MerchantCategory RawEventRate Count SmoothedEncoding
1 electronics 0.00 7 0.2058824
2 grocery 0.25 4 0.3214286
3 jewellery 0.60 5 0.4333333
4 travel 0.75 4 0.4642857
Every category’s raw event rate is pulled towards the 30% overall rate, and pulled hardest where a category has the
fewest rows (electronics here, at 7 rows, moves furthest from its raw 0%); this is the same smoothing formula
(n_c * rate_c + m * overallRate) / (n_c + m) the product tool uses, with m as the pseudo-observation count
Smoothing above.
Tool equivalent: Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.EncodingTools
/ tool "FitTargetMeanEncoder" (must be fit on training rows only, since the target leaks into the encoding) alongside
"ApplyCategoryEncoder" for scoring new rows from the fitted mapping. The same file’s
"FitWeightOfEvidenceEncoder" (the credit-scoring ln(event share / non-event share) encoding, which also reports each
column’s information value) and "FitFrequencyEncoder" (encodes a category by its raw count or share of rows, useful
for high-cardinality identifiers with no target at all) are the file’s other two target-aware/frequency-aware options,
both sharing "ApplyCategoryEncoder" to score new data.
"FitRareCategoryGrouping"/"ApplyRareCategoryGrouping" are worth knowing about alongside all of the above: they
collapse rare categories into a single other label before any of these encoders run, which caps how wide a one-hot
table gets and stops a scarcely-seen category from dominating a target-mean or weight-of-evidence cell.
Target-mean encoding with additive smoothing follows Micci-Barreca, D. (2001), “A Preprocessing Scheme for
High-Cardinality Categorical Attributes in Classification and Regression Problems,” ACM SIGKDD Explorations, 3 (1):
27–32 — the same (n_c * rate_c + m * prior) / (n_c + m) blend used in the R snippet above. Weight-of-evidence encoding
is standard credit-scoring practice rather than a single paper’s invention; Siddiqi, N., Credit Risk Scorecards:
Developing and Implementing Intelligent Credit Scoring (Wiley, 2006) is the standard reference for both the
ln(event share / non-event share) formula and the paired information-value statistic
Σ(event share - non-event share) * WoE.
Code audit: FitTargetMeanEncoder (EncodingTools.cs:148-169) implements Micci-Barreca’s formula verbatim —
(n * (e / (double)n) + smoothing * overall) / (n + smoothing) at EncodingTools.cs:169 — with m exposed as the
smoothing parameter (default 10 pseudo-observations), not an unsmoothed raw mean. FitWeightOfEvidenceEncoder
(EncodingTools.cs:171-204) adds its own Laplace-style smoothing to both the event and non-event shares before taking
the log (EncodingTools.cs:198-203, default pseudo-count 0.5), matching Siddiqi’s practical guidance to avoid ln(0)
on empty categories.
There is a genuine inconsistency worth flagging: the per-category weight-of-evidence values are smoothed, but the
column’s reported information value is computed separately in BuildEncoding
(EncodingTools.cs:450-461) from the raw, unsmoothed event and non-event shares, and silently contributes 0 for any
category with zero events or zero non-events instead of reusing the same smoothed shares as the WoE column sitting next
to it. The two numbers the tool returns side by side — a category’s weight of evidence and the column’s overall
information value — are therefore not computed on a consistent basis.
Feature Engineering: Cyclical Encoding of a Periodic Variable
An hour-of-day or day-of-week column is periodic: hour 23 and hour 0 are one hour apart, not 23. Encoding it as a single number (or as one-hot categories) hides that adjacency. Splitting it into a sine and a cosine component instead places every hour on a circle, so the numeric distance between encoded hours matches the real distance around the clock.
Procedure 15: Cyclical (Sine/Cosine) Encoding of Hour-of-Day
HourOfDay <- c(0, 6, 12, 18, 23)
HourSin <- sin(2 * pi * HourOfDay / 24)
HourCos <- cos(2 * pi * HourOfDay / 24)
round(data.frame(HourOfDay, HourSin, HourCos), 4)
Distance <- function(h1, h2) {
sqrt((sin(2*pi*h1/24) - sin(2*pi*h2/24))^2 + (cos(2*pi*h1/24) - cos(2*pi*h2/24))^2)
}
round(c(dist_23_to_0 = Distance(23, 0), dist_0_to_12 = Distance(0, 12)), 4)
Console output:
HourOfDay HourSin HourCos
1 0 0.0000 1.0000
2 6 1.0000 0.0000
3 12 0.0000 -1.0000
4 18 -1.0000 0.0000
5 23 -0.2588 0.9659
dist_23_to_0 dist_0_to_12
0.2611 2.0000
Hour 23 sits only 0.26 encoded units from hour 0 — genuinely close — while hour 0 sits a full 2.0 units from hour 12, the maximum possible distance on the circle: the encoding recovers the clock’s real geometry, which a plain 0–23 integer column cannot.
Tool equivalent:
Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.FeatureEngineeringTools
/ tool "EncodeCyclicalFeature" — returns the sin/cos pair for any period (24 for hour-of-day, 7 for day-of-week,
12 for month), computed with the identical sin(2*pi*x/period)/cos(2*pi*x/period)
formulas. The same file’s "GenerateLagFeatures" (lag/diff/rolling-window features for an already-time-ordered series,
optionally grouped by an account or entity key) and
"GeneratePolynomialFeatures" (products and powers of numeric columns up to a chosen degree, with an
interactionOnly option to skip pure powers) are the file’s other two feature-construction tools beyond simple
rescaling.
Alongside those, "GenerateColumnPairFeatures" builds named pairwise ratios, differences, sums, minimums and maximums
such as amount/balance, and "ExtractDateTimeParts"/"ExtractEpochParts" produce the raw hour-of-day, day-of-week and
similar integers that "EncodeCyclicalFeature" then turns cyclical.
Encoding a periodic variable as a sine/cosine pair is standard practice in circular statistics rather than a single named result — see Mardia, K. V. and Jupp, P. E., Directional Statistics (Wiley, 1999) for the general treatment of angular data as points on a unit circle, of which this is the applied feature-engineering case.
Code audit: EncodeCyclicalFeature (FeatureEngineeringTools.cs:428-446) computes
Math.Sin(2 * Math.PI * v / period) and Math.Cos(2 * Math.PI * v / period)
(FeatureEngineeringTools.cs:441-443) — the exact formula used in the R snippet above, for any caller-supplied period —
and returns NaN for both columns on a missing input rather than silently passing garbage through the trigonometric
functions.
