Skip to content

feat: array contains - #67

Merged
mantasu merged 8 commits into
ExpediaGroup:mainfrom
mantasu:feat/array-contains
Oct 1, 2026
Merged

mantasu merged 8 commits into
ExpediaGroup:mainfrom
mantasu:feat/array-contains

Conversation

@mantasu

@mantasu mantasu commented Jul 31, 2026

Copy link
Copy Markdown
Member

Description

Adds an ArrayContains transformer and paired Keras layer that check whether a scalar value is contained in an array feature, outputting 1.0 if present and 0.0 otherwise.

Keras Layer Checklist

  • The new Keras layer extends BaseLayer
  • The _call method has been implemented in the new layer.
  • The compatible_dtypes property is defined in the new layer.
  • The new layer is decorated with @tf.keras.utils.register_keras_serializable(package=kamae.__name__).
  • The new layer takes a name, input_dtype, and output_dtype as arguments to the constructor and that this is passed to the super constructor.
  • The Keras layer is serializable. I have implemented the get_config method.
  • There are unit tests of the new layer.
  • There is a specific test of layer serialisation added here.
  • The new layer is imported in the init.py file in the layers directory.

Spark Transformer/Estimator Checklist

  • The new Spark Transformer extends BaseTransformer.
  • If the new transform needs a fit method, a Spark Estimator has been implemented that extends BaseEstimator.
  • The instructions in the above docs page have been followed for the __init__ and setParams methods.
  • The transformer uses one of the input/output mixin classes from base.py.
  • If the new transformer requires more parameters that would need to be serialised to the Spark ML pipeline, there is a implemented parameter class by extending the Params class here.
  • The compatible_dtypes property has been implemented to specify the input/output data types that my transformer/estimator supports.
  • A Keras subclassed layer is returned in the transformer's get_tf_layer method.
  • There are unit tests of the new transform. In particular, there are parity tests between the Spark and Keras implementations.
  • The new transformer/estimator is imported in the init.py file in the transformers/estimators directory.

Readme Checklist

  • There is a new entry (alphabetical order) in the README table describing the new layer/transformer

@mantasu
mantasu requested a review from a team as a code owner July 31, 2026 12:22
@mantasu
mantasu requested review from ddonghi and jacobjwood July 31, 2026 12:22
@mantasu

mantasu commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Could I please be also given write access so I wouldn't need to create the fork? 🙏

@georyetti georyetti left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments:

  • Keras layer can contain more params to make it more flexible. We can set these as fixed from Spark side
  • I think this can return booleans and the user can set output dtypes for auto casting to floats
  • Can we use the Spark array helper functions to support nested arrays

raise ValueError(f"Expected 2 inputs, got {len(inputs)} inputs instead.")

array, value = inputs
any_match = ops.any(ops.equal(array, value), axis=-1, keepdims=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make axis a param of this layer? Then its more useful for a user that just reuses keras layers directly? We can set axis=-1 from Spark side

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for keepdims too pls

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅

Comment on lines +141 to +145
output_col = (
F.when(F.array_contains(F.col(arr_c), F.col(val_c)), F.lit(1.0))
.otherwise(F.lit(0.0))
.cast(DoubleType())
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reuse the helper methods we have for array computation here? Then it would support nested arrays also in Spark.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅

Comment on lines +69 to +76
"int8",
"uint8",
"int16",
"uint16",
"int32",
"uint32",
"int64",
"uint64",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work for int values? Or just int arrays? If so can we add a test please. All tests currently are just with float values

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah should work for both arrays and scalars, added a test


array, value = inputs
any_match = ops.any(ops.equal(array, value), axis=-1, keepdims=True)
return ops.cast(any_match, "float32")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it expected that array contains returns a float32? I would expect a boolean. The user can set output_dtype="float32" to get this behaviour so I would say we should preserve booleans here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅

@mantasu
mantasu force-pushed the feat/array-contains branch from 5583b8b to 8133b18 Compare September 3, 2026 08:04

@georyetti georyetti left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments:

  1. Pedantic one about using helpers, you have used some more internal ones, we have an easier wrapper for this.
  2. Do we want to allow a pattern where the scalar value is just a normal constant. Like we do with the Divide/Multiply transformers?

Comment on lines +140 to +152
nesting_level, elem_t = get_array_nesting_level_and_element_dtype(arr_t)
if not isinstance(elem_t, _NUMERIC_TYPES):
raise TypeError(f"arrayCol '{arr_c}' element must be numeric, got {elem_t}")

if not isinstance(val_t, _NUMERIC_TYPES):
raise TypeError(f"valueCol '{val_c}' must be numeric, got {val_t}")

# Apply array_contains at the innermost level
contains_func = nested_transform(
func=lambda x: F.array_contains(x, F.col(val_c)),
nest_level=nesting_level - 1,
)
output_col = contains_func(F.col(arr_c))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a helper for this that makes it a bit simpler, can we use this? single_input_single_output_array_transform
Also there is no need to check for numeric types as this is done by the BaseTransformer using compatible_dtypes

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅

Comment on lines +71 to +80
inputCols: Optional[List[str]] = None,
outputCol: Optional[str] = None,
inputDtype: Optional[str] = None,
outputDtype: Optional[str] = None,
layerName: Optional[str] = None,
) -> None:
"""
Initializes an ArrayContainsTransformer transformer.

:param inputCols: Input column names, given as `[arrayCol, valueCol]`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to allow a single input col and a constant? So we can check if array_contains(x, -1) for example?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair idea, generalised it

georyetti
georyetti previously approved these changes Sep 15, 2026

@georyetti georyetti left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

output_col = single_input_single_output_array_transform(
input_col=array_col,
input_col_datatype=self.get_column_datatype(df, df.columns[0]),
func=lambda x: F.array_contains(x, value_col),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wanted to doulbe-check, array_contains can return NULL too, not just True/False, while for the Keras side ops.any(ops.equal(...)) always gives a boolean. if the array contains even a single NULL, array contains will result NULL overall. Is this the desired behaviour, to propagate the NULL? If not we can maybe wrap it with an explcit check?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, wrapped in F.coalesce(..., F.lit(False))

else:
raise ValueError(f"Expected 2 inputs, got {len(inputs)} inputs instead")

return ops.any(ops.equal(array, value), axis=self.axis, keepdims=self.keepdims)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the equality check here identical to what array_contains does on the Spark side? Just trying to understand whether there's any chance of a mismatch on exact equality.

I see above (line 111) the cast only runs for the constant branch, so I wasn't sure what happens when the value comes in as a separate column with a different dtype.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both use exact equality and inputDtype casts the array and value to the same dtype

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Mantas!

The inputDtype point holds when it's defined, but it defaults to None, and then nothing aligns the array and the constant, so each side reconciles float vs double its own way:

Spark widens the array to double, and Keras on the opposite direction narrows the constant to float32. It might be pedantic, since this only matters for constants that aren't exactly representable in float32, like 0.1: array_contains([0.1f], 0.1) → false in Spark (0.10000000149 ≠ 0.1), but true in Keras. (I checked the Spark side locally with claude.)

Could we cast the literal to the array's element type on the Spark side (or mirror Spark's widening in Keras), and add a parity test with inputDtype=None and a constant like 0.1 (that float32 is not enough to describe it) on a float array? The current parity tests only use integer arrays and integer constants (5.0 and 0.0), so the float path isn't covered.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thank you! Made it so that behaviour is identical to #L111

@Sbranikas Sbranikas left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mantasu
mantasu merged commit abce236 into ExpediaGroup:main Oct 1, 2026
28 checks passed
@mantasu
mantasu deleted the feat/array-contains branch October 1, 2026 07:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants