OpenML developer on retainer: lambda function in FunctionTransformer raises PicklingError when openml.runs.publish() serializes the pipeline — 48 of 50 task uploads fail — 3 weeks — OpenML developer on monthly retainer

openml 0.14.x • Python 3.11 • scikit-learn 1.4.x • Pipeline([('log_transform', FunctionTransformer(lambda x: np.log1p(x))), ...]) • openml.runs.run_model_on_task(clf, task) succeeds for all 50 tasks • openml.runs.publish() calls joblib.dump(pipeline) internally • lambda has no module-level qualified name • PicklingError: Can't pickle <function <lambda>> for 48/50 tasks • developer checks openml version, joblib version, scikit-learn version for 3 weeks • never runs pickle.dumps(pipeline) locally • fix: FunctionTransformer(np.log1p) — named function reference is picklable • all 50 tasks upload • 1.5h retainer engagement

Northfield OpenML Research Ltd. used openml 0.14.x to benchmark a custom scikit-learn pipeline against 50 OpenML classification tasks. The pipeline included a log-normalization step using a FunctionTransformer with a lambda function: FunctionTransformer(lambda x: np.log1p(x)). The pipeline trained successfully for all 50 tasks and produced predictions — openml.runs.run_model_on_task(clf, task) returned a Run object for each task with no errors. When the developer called run.publish() to upload the results to openml.org, 48 of 50 tasks raised a PicklingError. The 2 tasks that succeeded were the two tasks where the lambda was not exercised (edge cases where all feature values were zero and the log transform was a no-op). The developer spent three weeks checking openml version, joblib version, and scikit-learn version before engaging an OpenML retainer developer.

The pipeline definition and upload loop at the time of the engagement:

# Northfield OpenML Research Ltd. — OpenML pipeline benchmark
# openml 0.14.x, Python 3.11, scikit-learn 1.4.x

import openml
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer, StandardScaler
from sklearn.ensemble import RandomForestClassifier

openml.config.apikey = 'MY_API_KEY'
openml.config.server = 'https://www.openml.org/api/v1/xml'

# Custom pipeline with FunctionTransformer using a lambda
# ← BUG: lambda functions are not picklable
pipeline = Pipeline([
    ('log_transform', FunctionTransformer(lambda x: np.log1p(x))),  # ← BUG
    ('scaler', StandardScaler()),
    ('clf', RandomForestClassifier(n_estimators=200, random_state=42)),
])

# 50 OpenML classification tasks
task_ids = [1, 2, 3, 6, 11, 12, 14, 15, 16, 18, 22, 23, 24, 26, 28, 29,
            31, 32, 36, 37, 39, 40, 41, 43, 44, 46, 50, 53, 54, 55, 56, 58,
            59, 60, 61, 70, 71, 76, 83, 93, 95, 100, 109, 115, 119, 123,
            125, 126, 129, 134]

results = []
for task_id in task_ids:
    task = openml.tasks.get_task(task_id)

    # This succeeds for ALL 50 tasks — pipeline trains fine locally
    run = openml.runs.run_model_on_task(pipeline, task)

    # ← This fails for 48/50 tasks with PicklingError
    # openml.runs.publish() calls joblib.dump(pipeline) internally
    # joblib uses pickle to serialize the pipeline object
    # pickle needs a qualified name to serialize each callable
    # lambda has no module-level name → PicklingError
    try:
        run.publish()
        results.append({'task_id': task_id, 'status': 'ok', 'run_id': run.run_id})
    except Exception as e:
        results.append({'task_id': task_id, 'status': 'failed', 'error': str(e)})

# After 50 tasks:
# 2 succeeded (task_ids where all features happened to be non-negative integers
#   with no log transform path hit — edge cases)
# 48 failed with:
#   PicklingError: Can't pickle  at 0x7f4a8b2e3d30>:
#   attribute lookup  on __main__ failed

Why lambda functions fail pickle serialization in OpenML pipelines

Python’s pickle module serializes objects by recording the information needed to reconstruct them in a fresh Python process. For simple values like integers and strings, pickle records the value directly. For functions and classes, pickle records the qualified name — the module path and attribute name that another Python process can use to look up the same object. For example, numpy.log1p is serialized as the string pair ('numpy', 'log1p'). When deserialized, Python executes import numpy; return getattr(numpy, 'log1p'). This works because log1p is a stable, module-level attribute of numpy that exists in every Python environment.

Lambda functions are anonymous — they have no module-level name. A lambda defined as lambda x: np.log1p(x) has a __qualname__ of <lambda> and exists only in the memory of the current Python process. Pickle cannot serialize it by name because there is no ('some_module', '<lambda>') pair that another Python process could use to reconstruct it. The serialization fails with PicklingError: Can't pickle <function <lambda> at 0x...>: attribute lookup <lambda> on __main__ failed.

When openml.runs.publish() is called, it serializes the trained pipeline object using joblib.dump() (which uses pickle under the hood). The FunctionTransformer(lambda x: np.log1p(x)) is part of the pipeline’s __dict__. When joblib walks the pipeline object tree and encounters the lambda, it raises PicklingError. The error occurs after training is complete — the pipeline has already produced predictions and stored them in the Run object. The developer sees the error only during upload, not during training or evaluation.

# Diagnostic: test pickle serialization before running any OpenML tasks
# openml 0.14.x, Python 3.11, scikit-learn 1.4.x

import pickle
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer, StandardScaler
from sklearn.ensemble import RandomForestClassifier
import numpy as np

# The problematic pipeline
pipeline_bad = Pipeline([
    ('log_transform', FunctionTransformer(lambda x: np.log1p(x))),  # ← lambda
    ('scaler', StandardScaler()),
    ('clf', RandomForestClassifier(n_estimators=200, random_state=42)),
])

# Test pickle serialization in 1 second — before running 50 tasks
try:
    serialized = pickle.dumps(pipeline_bad)
    print("Pipeline is picklable — OK to upload to OpenML")
except Exception as e:
    print(f"Pipeline is NOT picklable: {e}")
    print("Fix: replace lambda with a named function reference")

# Output:
# Pipeline is NOT picklable:
# Can't pickle  at 0x7f4a8b2e3d30>:
# attribute lookup  on __main__ failed

# This 1-second test would have prevented 48 hours of benchmark runs
# followed by PicklingError on every openml.runs.publish() call

# Check __qualname__ to understand why lambda is not picklable
log_fn_lambda = lambda x: np.log1p(x)
print(f"Lambda __qualname__: {log_fn_lambda.__qualname__}")
# Output: Lambda __qualname__:    ← no module-level name

print(f"np.log1p __qualname__: {np.log1p.__qualname__}")
# Output: np.log1p __qualname__: log1p   ← stable module-level name

# joblib/pickle uses __qualname__ to serialize callables
# 'log1p' maps to numpy.log1p — reconstructable in any Python process
# '' has no stable mapping — PicklingError

Fix: use a named function reference instead of a lambda

The fix is to replace the lambda with a direct reference to the named numpy function: FunctionTransformer(np.log1p). The reference np.log1p is the log1p attribute of the numpy module. When pickle serializes this, it stores ('numpy', 'log1p') and reconstructs it by looking up numpy.log1p in a fresh Python process. Because numpy.log1p is a stable function in every numpy installation, the deserialization always succeeds.

FunctionTransformer(np.log1p) and FunctionTransformer(lambda x: np.log1p(x)) produce identical numerical outputs for every input. The lambda is a wrapper that calls np.log1p with the same argument — it adds no additional behavior. The fix is a pure substitution with no change in behavior, only in serializability. For any custom transformation that cannot be expressed as a single named function (e.g., log1p + clip + normalize), the solution is to define a module-level function: def log_clip_normalize(X): return np.clip(np.log1p(X), 0, 10) / 10 and use FunctionTransformer(log_clip_normalize).

# Fix: replace lambda with a named function reference
# openml 0.14.x, Python 3.11, scikit-learn 1.4.x

import openml
import pickle
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer, StandardScaler
from sklearn.ensemble import RandomForestClassifier

openml.config.apikey = 'MY_API_KEY'

# ← Fixed: FunctionTransformer(np.log1p) instead of lambda
pipeline_fixed = Pipeline([
    ('log_transform', FunctionTransformer(np.log1p)),  # ← Fixed: named function
    ('scaler', StandardScaler()),
    ('clf', RandomForestClassifier(n_estimators=200, random_state=42)),
])

# Smoke test: verify pickle serialization before any task runs
serialized = pickle.dumps(pipeline_fixed)
print(f"Pipeline serialized: {len(serialized)} bytes")  # Pipeline serialized: 4821 bytes
deserialized = pickle.loads(serialized)
print(f"Pipeline deserialized: {deserialized}")         # Pipeline(...) — OK

# Same 50 tasks
task_ids = [1, 2, 3, 6, 11, 12, 14, 15, 16, 18, 22, 23, 24, 26, 28, 29,
            31, 32, 36, 37, 39, 40, 41, 43, 44, 46, 50, 53, 54, 55, 56, 58,
            59, 60, 61, 70, 71, 76, 83, 93, 95, 100, 109, 115, 119, 123,
            125, 126, 129, 134]

results = []
for task_id in task_ids:
    task = openml.tasks.get_task(task_id)
    run = openml.runs.run_model_on_task(pipeline_fixed, task)

    # ← No PicklingError — np.log1p is a named picklable function
    run.publish()
    results.append({'task_id': task_id, 'status': 'ok', 'run_id': run.run_id})

print(f"Successfully uploaded: {len([r for r in results if r['status'] == 'ok'])}/50")
# Output: Successfully uploaded: 50/50

# ---- After fix: summary ----
# Pipeline:          FunctionTransformer(np.log1p) → picklable
# Pickle test:       pickle.dumps(pipeline) succeeds, 4821 bytes
# Tasks uploaded:    50/50 (was 2/50)
# Run IDs:           50 unique run IDs on openml.org

# For multi-step transformations, use a module-level function:
# def preprocess(X):
#     return np.clip(np.log1p(X), 0, 10) / 10
#
# pipeline_multi = Pipeline([
#     ('preprocess', FunctionTransformer(preprocess)),  # ← module-level fn: picklable
#     ('clf', RandomForestClassifier()),
# ])
# pickle.dumps(pipeline_multi)  # OK — 'preprocess' is a named module-level function

# ---- Hour breakdown ----
# Triage:     0.5h — 48/50 openml.runs.publish() raise PicklingError;
#             error message inspected; 'lambda' keyword found in FunctionTransformer;
#             pickle.dumps(pipeline) run locally; PicklingError reproduced in 1s
# Root cause: 0.25h — lambda has no module-level qualified name;
#             pickle requires named functions for serialization;
#             joblib.dump() used by openml.runs.publish() internally
# Fix:        0.25h — FunctionTransformer(lambda x: np.log1p(x)) →
#             FunctionTransformer(np.log1p)
# Validation: 0.5h — pickle.dumps(pipeline_fixed) succeeds;
#             all 50 openml.runs.publish() complete; 50/50 run IDs logged; 1.5h total

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →