Can't pickle <function <lambda>>" PicklingErrorA scikit-learn pipeline with a small inline transform can run for weeks under cross_val_score or GridSearchCV without incident, then fail the moment someone sets backend='multiprocessing' — often copied from an old answer, or picked deliberately to sidestep a different problem. Reproduced below with joblib 1.6.0 and scikit-learn 1.9.0 (Python 3.11), including why the exact same code works under joblib's own default backend.
The plain version, stripped down to just joblib, with an explicit backend='multiprocessing':
from joblib import Parallel, delayed
def make_worker():
def worker(x):
return x * 2
return worker
w = make_worker()
results = Parallel(n_jobs=2, backend="multiprocessing")(delayed(w)(i) for i in range(4))
# Traceback (most recent call last):
# ...
# AttributeError: Can't pickle local object 'make_worker.<locals>.worker'
With a bare lambda instead of a nested closure, the same backend raises a slightly different, equally direct message:
import joblib
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
# ... pipeline built with FunctionTransformer(lambda x: ...), see below ...
# joblib.externals.loky.process_executor._RemoteTraceback:
# PicklingError: Can't pickle <function <lambda> at 0x7f4203d984a0>:
# attribute lookup <lambda> on __main__ failed
Both boil down to the same constraint: standard pickle can only serialize a function by recording where to re-import it from — module name plus a plain top-level attribute name. A lambda, a nested closure, or any function defined inside another function has no such dotted path, so pickle has nothing to write down.
Leave backend unset and the identical closure-based example above runs with no error at all:
from joblib import Parallel, delayed
def make_worker():
def worker(x):
return x * 2
return worker
w = make_worker()
results = Parallel(n_jobs=2)(delayed(w)(i) for i in range(4))
print(results) # [0, 2, 4, 6] — no error, default backend is loky
loky, serializes the work sent to its worker processes with cloudpickle rather than the standard library's pickle. cloudpickle can serialize lambdas, nested closures, and most other local functions by capturing their actual code object, not just an import path — which is exactly why the closure example above raises nothing under the default backend but fails the instant backend="multiprocessing" forces plain pickle instead.scikit-learn's own n_jobs parameter is a thin wrapper over joblib, so the same backend switch breaks a Pipeline the same way — and this is the shape it actually takes in practice, since scikit-learn code rarely calls Parallel directly:
import joblib
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
import numpy as np
X = np.random.rand(50, 4)
y = np.random.randint(0, 2, 50)
pipe = Pipeline([
('log', FunctionTransformer(lambda x: np.log1p(np.abs(x)))),
('clf', LogisticRegression()),
])
with joblib.parallel_backend('multiprocessing'):
scores = cross_val_score(pipe, X, y, cv=3, n_jobs=2)
# PicklingError: Can't pickle <function <lambda> at 0x...>:
# attribute lookup <lambda> on __main__ failed
Remove the with joblib.parallel_backend('multiprocessing'): block and run the exact same pipeline and it completes normally — confirmed on the same environment, same lambda, same n_jobs=2. Nothing about the pipeline itself is wrong; only the backend context around it decides whether the lambda is a problem.
Give the function a real dotted import path by defining it at module level instead of inline:
import joblib
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
import numpy as np
def log1p_abs(x):
return np.log1p(np.abs(x))
if __name__ == "__main__":
X = np.random.rand(50, 4)
y = np.random.randint(0, 2, 50)
pipe = Pipeline([
('log', FunctionTransformer(log1p_abs)),
('clf', LogisticRegression()),
])
with joblib.parallel_backend('multiprocessing'):
scores = cross_val_score(pipe, X, y, cv=3, n_jobs=2)
print(scores) # runs cleanly under multiprocessing now
log1p_abs version above completes under backend='multiprocessing' with no error, and the same fix (pulling a nested closure out to module scope) also resolved the plain joblib example from the first section. The if __name__ == "__main__": guard matters here too — the multiprocessing backend re-imports the launching module in each worker process, and without the guard that re-import re-runs the whole script.The other equally valid fix, when nothing actually depends on the multiprocessing backend specifically, is to just not set it — joblib's own loky default already handles lambdas and closures via cloudpickle, per the previous section.
A module-level function still needs to be pickled with whatever extra arguments it was bound to. functools.partial over a module-level function pickles correctly under multiprocessing, same as a plain function:
from functools import partial
from joblib import Parallel, delayed
def scaled_worker(factor, x):
return x * factor
w = partial(scaled_worker, 3)
results = Parallel(n_jobs=2, backend="multiprocessing")(delayed(w)(i) for i in range(4))
print(results) # [0, 3, 6, 9] — no error
scaled_worker itself is at module level — wrapping a lambda or a closure in functools.partial doesn't change what's underneath it; partial still has to pickle the function it wraps, and hits the exact same AttributeError if that function has no dotted import path.