Data Frames vs the Packed Array
This page does not carry over a procedure from the original R training guide. It looks directly at the analogy every
other page in this section leans on implicitly: an R data.frame versus the product’s own
“DataFrame,” and how the product’s AdvancedAnalytics tools do the dplyr-shaped work of shaping one.
Data Structures (procedures 18-19) covers constructing a data.frame from vectors and factors,
and Loading, Shaping and Merging Data (procedures 11-15) covers
arrange/select/mutate/merging/deleting columns on one — this page does not repeat that walkthrough. It takes the
concepts those procedures established as read and asks what survives the trip into the product.
1. What R’s data.frame is, and what the product’s array is
An R data.frame is a list of named vectors (and factors) of equal length, one vector per column, each with its own
type. A column can be numeric, character, logical or a factor; the whole object carries its own column names, and R
decides at each step whether an operation still returns something type-consistent. That flexibility is exactly what
module 3’s data-frame procedures and module 4’s shaping procedures rely on: select() and mutate() reference columns
by name, arrange() reorders rows and takes every column along with it, and a factor column stays a factor until
something explicitly coerces it.
The product’s equivalent, described in full in
Numeric Data Representation, is a different structure with a
narrower job. AdvancedAnalyticsCache (Jube.Data/Repository/AdvancedAnalyticsCacheRepository.cs, POCO in
Jube.Data/Poco/AdvancedAnalyticsCache.cs) stores one packed numeric table per cache entry:
Data— abyteaholding a flatdouble[], packed byJube.Data.Repository.AdvancedAnalyticsPacking.Pack(double[] values)and reversed byAdvancedAnalyticsPacking.Unpack(byte[] bytes, int packingVersion). Packing version 1 is IEEE 754 binary64, little endian, one value every 8 bytes, row major (a row’s columns are contiguous, then the next row follows).RowsandColumns— plainintcolumns on the same cache row, carried beside the bytes because the bytes alone have no shape.AdvancedAnalyticsCacheRepositoryrequiresRows * Columns * 8to equal the data length for packing version 1.
That is the whole structure: a positional, unnamed, all-numeric matrix flattened to one dimension, plus two integers
telling you how to re-fold it. Compared with a data.frame, three things are lost and one thing is gained:
- Lost: column names. The array has no header row. Column 0 is just column 0. Names live elsewhere — see below.
- Lost: mixed types. Every cell is a
double. A categorical column (R’s factor, or a plain character vector) cannot go into the array as text; it has to already be encoded to a number — a label index, a one-hot column, a target-mean or frequency value — before packing.Jube.Service/Agent/ AdvancedAnalyticsTools/DataPreparationAndResampling/EncodingTools.csis where that encoding happens (FitLabelEncoder,FitOneHotEncoder,FitTargetMeanEncoder,FitWeightOfEvidenceEncoder,FitFrequencyEncoderand theirApply*counterparts); it runs before a table is packed, never after. - Lost: the row/column duality of a list-of-vectors. A
data.frameis column-oriented (each column is one vector); the packed array is row-major bytes with no column boundaries of its own —Columnsis metadata, not structure. - Gained: a byte-exact, storable, poolable representation. A
data.framelives in one R session’s memory. The packed array is bytes in a Postgresbytea, addressed by aGuidpointer (AdvancedAnalyticsCache.Guid), with a size limit, a tenant quota and an expiry (AdvancedAnalyticsCacheTtlSeconds,AdvancedAnalyticsCacheMaxEntryBytes,AdvancedAnalyticsCacheMaxTenantBytes— see FoundationData.md). It is built to be handed aGuidat a time, not held open as a live object across a whole analysis.
Where the names live instead. Variable names and their order are not part of the array — they are a separate
metadata table, AdvancedAnalyticsTrainingRunVariable (VariableSequence, Name, Mean,
StandardDeviation, Minimum, Maximum, ImputationValue, MissingCount, RowCount, Importance), recorded once a
training run has actually used the columns, and VariablesJson on
AdvancedAnalyticsTrainedModelVersion, recorded once a model version is trained. Neither is attached to the cache entry
itself — a cache row is pure positional data, and nothing stops two different training runs disagreeing about what
column 3 of the same cache pointer means, provided each run is honest about its own
VariableSequence.
Where a data.frame’s shape actually shows up before packing. The agent tool layer does not receive the packed bytes
directly. Tools under DataPreparationAndResampling take a double[][] — one double[] per row — plus a separate
string[] columnNames parameter when a name is needed at all
(FeatureTableResult(string[] ColumnNames, double[][] Table) is the common return shape, for example from
SelectTableColumns). That double[][] plus a parallel name array is the closest the product gets to a
data.frame in memory: still no factors, still no mixed types, but at least named and shaped by construction rather
than by two accompanying integers. Packing to the flat AdvancedAnalyticsCache array is a separate, later step — the
format a table is put in to be stored or handed to a training run
(Jube.Engine/AdvancedAnalytics/Training/AdvancedAnalyticsTrainingInputResolver.cs unpacks a cache entry and reshapes
it into a double[][] of predictors plus an int[] of labels by walking
values[r * columns + c], splitting out one designated label column as it goes) — not the format the interactive tools
reason over call to call.
2. Worked example: a data.frame, by hand, into the packed array
A tiny data.frame, run for real in the validation container, in the same shape module 3’s procedures 18 and 19 build
(numeric columns plus one factor):
Transactions <- data.frame(
TransactionId = 1:6,
Amount = c(120.50, 45.00, 980.25, 12.75, 305.60, 77.10),
Balance = c(500, 300, 1000, 50, 900, 200),
Channel = factor(c("Card","Online","Card","Online","Card","Online"))
)
print(Transactions)
str(Transactions)
Console output:
TransactionId Amount Balance Channel
1 1 120.50 500 Card
2 2 45.00 300 Online
3 3 980.25 1000 Card
4 4 12.75 50 Online
5 5 305.60 900 Card
6 6 77.10 200 Online
'data.frame': 6 obs. of 4 variables:
$ TransactionId: int 1 2 3 4 5 6
$ Amount : num 120.5 45 980.2 12.8 305.6 ...
$ Balance : num 500 300 1000 50 900 200
$ Channel : Factor w/ 2 levels "Card","Online": 1 2 1 2 1 2
str() already shows the problem: three numeric vectors and one factor stored as an integer code (1 = Card, 2 = Online)
behind the label. To pack this table the factor has to be turned into a real number column first — the R side of that is
exactly what as.numeric(Channel == "Online") did in module 4’s horizontal-abstraction procedures, and it is what
EncodingTools does on the product side. Take the frame after that encoding (IsOnline replacing Channel, four
numeric columns: TransactionId, Amount,
Balance, IsOnline) and flatten it row by row:
row 1: 1, 120.50, 500, 0
row 2: 2, 45.00, 300, 1
row 3: 3, 980.25, 1000, 0
row 4: 4, 12.75, 50, 1
row 5: 5, 305.60, 900, 0
row 6: 6, 77.10, 200, 1
Row major means those six rows are concatenated in order, giving a 24-element double[]:
[1, 120.50, 500, 0, 2, 45.00, 300, 1, 3, 980.25, 1000, 0, 4, 12.75, 50, 1, 5, 305.60, 900, 0, 6, 77.10, 200, 1]
AdvancedAnalyticsPacking.Pack(double[] values) writes each of those 24 doubles as 8 little-endian bytes
(BinaryPrimitives.WriteInt64LittleEndian over BitConverter.DoubleToInt64Bits(values[i])), giving a 192-byte array.
That byte array becomes AdvancedAnalyticsCache.Data; Rows = 6 and Columns = 4 are stored alongside it as plain
columns on the same row, and Rows * Columns * 8 = 192 is exactly the byte count the repository checks for packing
version 1. Unpack(byte[] bytes, int packingVersion) reads the same 8-byte spans back through
BinaryPrimitives.ReadInt64LittleEndian and
BitConverter.Int64BitsToDouble, returning the flat 24-element double[] — at that point the caller still has to know
Rows and Columns to fold it back into anything resembling the original table, and it still has no column names or
factor: those came from TransactionId/Amount/Balance/IsOnline, remembered only by whichever
AdvancedAnalyticsTrainingRunVariable rows or VariablesJson a training run wrote down for this table, never by the
cache entry itself.
3. Filtering side by side: dplyr vs the packed-array tools
A full verb-by-verb accounting of what’s covered, what’s genuinely missing, and which gaps are worth shimming directly
on the array versus better left to the SQL layer upstream of AdvancedAnalyticsCache is
in DplyrGapAnalysis.md.
The dplyr pipeline below runs mutate() (encode the factor), filter(), select() and arrange() in the order module
4’s procedures introduce them (mutate: procedure 13; select: procedure 12; arrange: procedure 11), on the same
Transactions frame, actually run in the validation container:
library(dplyr)
Result <- Transactions %>%
mutate(IsOnline = as.numeric(Channel == "Online")) %>%
filter(Amount > 50) %>%
select(TransactionId, Amount, Balance, IsOnline) %>%
arrange(desc(Amount))
print(Result)
Console output:
TransactionId Amount Balance IsOnline
1 3 980.25 1000 0
2 5 305.60 900 0
3 1 120.50 500 0
4 6 77.10 200 1
Below each dplyr verb is the real tool (or honest gap) that does the equivalent work on a packed/positional table.
mutate () — add a derived column
mutate(Transactions, IsOnline = as.numeric(Channel == "Online")) adds a computed column to the existing frame in
place. There is no single “add a column to this table” tool; instead each feature-engineering tool in
Jube.Service/Agent/AdvancedAnalyticsTools/DataPreparationAndResampling/FeatureEngineeringTools.cs
takes a double[][] data table and returns a new FeatureTableResult holding only the generated column (s) — the
caller concatenates that onto the columns it wants to keep. For this specific case (turning a category into a 0/1
indicator) the real mapping is one step earlier, in EncodingTools.cs:
FitOneHotEncoder/ApplyOneHotEncoder (or FitLabelEncoder for an ordinal code) does what
as.numeric(Channel == "Online") did by hand. For arithmetic on existing numeric columns —
Interim_Close + (Interim_Close / 2) in module 4’s own example — the equivalent is
GenerateColumnPairFeatures (product, ratio, sum, difference, absolute difference, minimum, maximum of two named
columns) or GeneratePolynomialFeatures; for a value computed against an earlier row rather than another column,
GenerateLagFeatures; for splitting a date-time string into numeric parts,
ExtractDateTimeParts (or ExtractEpochParts for a Unix timestamp). None of them mutate the table that was passed in —
every tool call is a pure function from one table to a new one, so “adding a column” is
“call the tool, then concatenate its output columns onto the ones you kept,” not “mutate the frame in place.”
AdvancedAnalyticsTool equivalent:
Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.EncodingTools / tools "FitOneHotEncoder",
"ApplyOneHotEncoder" for this specific factor-to-number step; FeatureEngineeringTools / tools
"GenerateColumnPairFeatures", "GenerateLagFeatures", "ExtractDateTimeParts", "ExtractEpochParts" for general
derived-column work.
filter () — keep matching rows
filter(Amount > 50) drops rows on a logical test and keeps every column of the surviving rows together. The
packed-array tools split this into two steps that dplyr does in one. A row-selecting tool inspects the table and returns
which row indices qualify — for example DataQualityTools.FindDuplicateRows (rows that exactly repeat an earlier row)
or the sampling tools in SamplingTools.cs such as SampleStratified
(drawn indices per stratum) and SampleSimpleRandom — but returns an int[] of row numbers, not a reduced table.
Building the actual reduced table from a plain Amount > 50-style test is left to the caller constructing the next
data argument from those indices; there is no single tool that takes a table plus a threshold and returns “rows where
column X compares to a scalar” the way filter() does generically. The one row-filtering tool that does return a
reduced table directly is a specific case:
MissingValueTools.DropSparseRowsAndColumns, which drops columns then rows by missing-value fraction and hands back the
smaller double[][] plus which original row and column indices survived, so labels and other parallel arrays can be
filtered identically.
AdvancedAnalyticsTool equivalent: for missingness-driven row dropping,
Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.MissingValueTools
/ tool "DropSparseRowsAndColumns" returns the reduced table directly. For a general logical condition on one column
(Amount > 50), there is no single-call equivalent — the closest building blocks are the row-index-producing tools
(DataQualityTools / "FindDuplicateRows", SamplingTools /
"SampleStratified", "SampleSimpleRandom", "SampleWeighted"), and the caller applies the returned indices itself
when it builds the next tool call’s table.
select () — keep named columns
select(Transactions, TransactionId, Amount, Balance, IsOnline) truncates the frame to named columns in the order
given. This one is a direct, exact match: FeatureEngineeringTools.SelectTableColumns takes the
double[][] data, the string[] columnNames naming every existing column, and a string[] keep naming the columns to
retain in the desired output order, rejecting an unknown name with the list of valid ones — the same contract as
select(), minus the ability to invoke it inline inside a longer expression.
AdvancedAnalyticsTool equivalent:
Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.FeatureEngineeringTools / tool
"SelectTableColumns" — exact match for keeping and reordering named columns.
Deleting a column (AAPL$Target <- NULL)
Module 4’s procedure 15 deletes a column by assigning NULL to it. The packed-array equivalent is the same tool run the
other way: call "SelectTableColumns" with keep listing every column except the one to drop. There is no separate
“delete a column” tool, because select()’s keep-list and “delete everything else” are the same operation from the
array’s side.
AdvancedAnalyticsTool equivalent:
Jube.Service.Agent.AdvancedAnalyticsTools.DataPreparationAndResampling.FeatureEngineeringTools / tool
"SelectTableColumns", called with the surviving column names.
arrange () — sort the whole table by a column
arrange(Result, desc(Amount)) reorders every row of the frame by one column’s values, every other column moving with
it. This is the one dplyr verb with no packed-array equivalent for a whole table. The numerics tool group has real
sorting tools —
Jube.Service/Agent/AdvancedAnalyticsTools/NumericsAndOptimisation/CombinatoricsAndUtilityTools.cs defines
"SortValuesWithOriginalIndices" (stable sort of one double[], returning the sorted values and the original
position of each so the order can be mapped elsewhere) and "SortValuesByKeys" (reorder one
double[] of values by the ascending order of a parallel double[] of keys) — but both work on a single vector, not a
double[][] table. Reordering a whole multi-column table by one column’s sort order is not a single tool call: the
caller would sort the key column with "SortValuesWithOriginalIndices" to get
OriginalIndices, then apply that same index order to every other column by hand, column by column, since no tool takes
a table plus a row-index permutation and returns the reordered table. Where a genuine table-wide row order matters going
into training, it is established earlier and elsewhere — SamplingTools
/ "SplitTimeOrdered" assumes rows already arrive in chronological order rather than sorting them, and the
Repository-layer query that populates a cache entry in the first place is where an ORDER BY would actually run, at the
SQL/query layer, before the table is ever packed. Inside the packed array itself, nothing sorts.
AdvancedAnalyticsTool equivalent: none for a whole table — R/script-runner only, or arrange upstream at the
SQL/query layer before packing. The closest tool-layer building blocks,
Jube.Service.Agent.AdvancedAnalyticsTools.NumericsAndOptimisation.CombinatoricsAndUtilityTools /
"SortValuesWithOriginalIndices" and "SortValuesByKeys", sort a single column, not a table.
inner_join () — merge two data frames
Module 4’s procedure 14 merges two data frames on a shared key column, and immediately hits a real R inconsistency: the
join runs, but Descriptions$Symbol is a factor with different levels to
AAPL$Symbol, so despite no error the join silently drops and duplicates rows around the mismatched levels — that
fragility is a strong argument for offloading the join. There is no dedicated join tool in
DataPreparationAndResampling; the guide’s own framing already puts this at the SQL layer (“Abstraction is offloaded to
Relational Database Management platforms” — module 4, opening paragraph), and nothing in the tool layer changes that:
joins across two sources happen in the query that builds a cache entry’s rows, not after packing.
AdvancedAnalyticsTool equivalent: none — join upstream at the SQL/query layer, same as the original guide already recommends; not a packed-array operation.
