Repository: joblib/joblib

Issue:
BUG: n_jobs=None not handled properly anymore in Parallel
At least on my machine and implicitly in [MNE-Python tests](https://github.com/mne-tools/mne-python/actions/runs/5421057815/jobs/9856038508?pr=11764):
```
>>> import joblib
>>> with joblib.parallel_config('loky', n_jobs=2):
...     print(joblib.Parallel(n_jobs=None).n_jobs)
... 
1
>>> with joblib.parallel_config('loky', n_jobs=2):
...     print(joblib.Parallel().n_jobs)
... 
2
>>> joblib.__version__
'1.3.1'
```
No idea why it happens. On my machine it's from PyPI on Linux.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/joblib/joblib/pull/1475?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib) Report
Patch coverage: **`100.00`**% and project coverage change: **`-25.42`** :warning:
> Comparison is base [(`476ff8e`)](https://app.codecov.io/gh/joblib/joblib/commit/476ff8e62b221fc5816bad9b55dec8883d4f157c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib) 94.94% compared to head [(`21372cf`)](https://app.codecov.io/gh/joblib/joblib/pull/1475?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib) 69.52%.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master    #1475       +/-   ##
===========================================
- Coverage   94.94%   69.52%   -25.42%     
===========================================
  Files          45       45               
  Lines        7474     7271      -203     
===========================================
- Hits         7096     5055     -2041     
- Misses        378     2216     +1838     
```


| [Impacted Files](https://app.codecov.io/gh/joblib/joblib/pull/1475?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib) | Coverage Δ | |
|---|---|---|
| [joblib/parallel.py](https://app.codecov.io/gh/joblib/joblib/pull/1475?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib#diff-am9ibGliL3BhcmFsbGVsLnB5) | `93.14% <100.00%> (-3.76%)` | :arrow_down: |
| [joblib/test/test\_config.py](https://app.codecov.io/gh/joblib/joblib/pull/1475?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib#diff-am9ibGliL3Rlc3QvdGVzdF9jb25maWcucHk=) | `80.24% <100.00%> (-17.35%)` | :arrow_down: |

... and [33 files with indirect coverage changes](https://app.codecov.io/gh/joblib/joblib/pull/1475/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/joblib/joblib/pull/1475?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=joblib).

jeremiedbb: It's not fully fixed yet, i'm still working out some details
tomMoral: Yes it is not fixed, it seems like we need to manage and test when `n_job` is not set on the context and `None` is passed explicitly
jeremiedbb: Actually, `n_jobs=None` should mean "unset" only for `Parallel`. For `parallel_backend`, since the default is -1, None used to mean "set" and was used to reset n_jobs to the backend default n_jobs in a nesting situation. `parallel_config` should behave the same.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 927790a..92e0654 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,13 @@
 Latest changes
 ==============
 
+Release 1.3.2 -- In developpement
+---------------------------------
+
+- Fix a regression in ``joblib.Parallel`` introduced in 1.3.0 where
+  explicitly setting ``n_jobs=None`` was not interpreted as "unset".
+  https://github.com/joblib/joblib/pull/1475
+
 Release 1.3.1 -- 2023/06/29
 ---------------------------
 
diff --git a/doc/_static/custom.css b/doc/_static/custom.css
index c931c7a..e0cb807 100644
--- a/doc/_static/custom.css
+++ b/doc/_static/custom.css
@@ -19,6 +19,12 @@ div.body h2 {
     color: #643200;
 }
 
+/* Third sections title */
+
+div.sphinxsidebar h3 {
+    font-size: xx-large;
+}
+
 /* Python examples code block */
 div.highlight pre {
     background-color: #f5f5f5;
@@ -31,7 +37,7 @@ div.highlight pre {
 div.body table.docutils tr{
     background: #ccc; /* fallback if nth-child is not supported */
 }
-div.body table.docutils tr:nth-child(odd){ 
+div.body table.docutils tr:nth-child(odd){
     background: #f8f4ee;
 }
 div.body table.docutils tr:nth-child(even){
@@ -52,3 +58,16 @@ div.sphinxsidebarwrapper ul ul:last-of-type {
     margin-top: 0px;
     margin-bottom: 20px;
 }
+
+/* Sidebar subtitle */
+p.caption {
+    font-size: x-large;
+    font-weight: 400;
+}
+
+div.body p.caption {
+    color: #643200;
+    font-size: 180%;
+    font-family: Georgia, serif;
+    font-weight: normal;
+}
\ No newline at end of file
diff --git a/doc/_templates/navigation.html b/doc/_templates/navigation.html
new file mode 100644
index 0000000..760dcc9
--- /dev/null
+++ b/doc/_templates/navigation.html
@@ -0,0 +1,10 @@
+<h3>{{ _('Navigation') }}</h3>
+{{ toctree(includehidden=theme_sidebar_includehidden, collapse=theme_sidebar_collapse) }}
+{% if theme_extra_nav_links %}
+<hr />
+<ul>
+    {% for text, uri in theme_extra_nav_links.items() %}
+    <li class="toctree-l1"><a href="{{ uri }}">{{ text }}</a></li>
+    {% endfor %}
+</ul>
+{% endif %}
diff --git a/doc/index.rst b/doc/index.rst
index 83892b6..d14d5a4 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -33,11 +33,9 @@ Introduction
 
 .. automodule:: joblib
 
-User manual
---------------
-
  .. toctree::
     :maxdepth: 2
+    :caption: User manual
 
     why.rst
     installing.rst
@@ -47,14 +45,15 @@ User manual
     auto_examples/index
     developing.rst
 
-Module reference
------------------
-
 .. currentmodule:: joblib
 
+Module reference
+----------------
+
 .. autosummary::
    :toctree: generated/
    :template: class.rst
+   :caption: Module reference
 
    Memory
    Parallel
@@ -68,3 +67,13 @@ Module reference
    load
    hash
    register_compressor
+
+Deprecated functionalities
+--------------------------
+
+.. autosummary::
+   :toctree: generated/
+   :template: class.rst
+   :caption: Deprecated functionalities
+
+   parallel_backend
\ No newline at end of file
diff --git a/examples/parallel_random_state.py b/examples/parallel_random_state.py
index 90cbaba..59f9901 100644
--- a/examples/parallel_random_state.py
+++ b/examples/parallel_random_state.py
@@ -77,14 +77,11 @@ print_vector(random_vector, backend)
 # early if that's the case:
 
 import multiprocessing as mp
-if mp.get_start_method() == "spawn":
-    import sys
-    sys.exit(0)
-
-backend = 'multiprocessing'
-random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
-    stochastic_function)(10) for _ in range(n_vectors))
-print_vector(random_vector, backend)
+if mp.get_start_method() != "spawn":
+    backend = 'multiprocessing'
+    random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
+        stochastic_function)(10) for _ in range(n_vectors))
+    print_vector(random_vector, backend)
 
 # %%
 # Some of the generated vectors are exactly the same, which can be a
@@ -107,9 +104,10 @@ def stochastic_function_seeded(max_value, random_state):
 # reset this seed by passing ``None`` at every function call. In this case, we
 # see that the generated vectors are all different.
 
-random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
-    stochastic_function_seeded)(10, None) for _ in range(n_vectors))
-print_vector(random_vector, backend)
+if mp.get_start_method() != "spawn":
+    random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
+        stochastic_function_seeded)(10, None) for _ in range(n_vectors))
+    print_vector(random_vector, backend)
 
 # %%
 # Fixing the random state to obtain deterministic results
@@ -120,12 +118,13 @@ print_vector(random_vector, backend)
 # we can replicate the same generation of vectors by passing a fixed state as
 # follows.
 
-random_state = np.random.randint(np.iinfo(np.int32).max, size=n_vectors)
+if mp.get_start_method() != "spawn":
+    random_state = np.random.randint(np.iinfo(np.int32).max, size=n_vectors)
 
-random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
-    stochastic_function_seeded)(10, rng) for rng in random_state)
-print_vector(random_vector, backend)
+    random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
+        stochastic_function_seeded)(10, rng) for rng in random_state)
+    print_vector(random_vector, backend)
 
-random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
-    stochastic_function_seeded)(10, rng) for rng in random_state)
-print_vector(random_vector, backend)
+    random_vector = Parallel(n_jobs=2, backend=backend)(delayed(
+        stochastic_function_seeded)(10, rng) for rng in random_state)
+    print_vector(random_vector, backend)
diff --git a/examples/serialization_and_wrappers.py b/examples/serialization_and_wrappers.py
index 0d8bbf8..fb7cb3c 100644
--- a/examples/serialization_and_wrappers.py
+++ b/examples/serialization_and_wrappers.py
@@ -61,7 +61,8 @@ print("With loky backend and cloudpickle serialization: {:.3f}s"
 # with the default pickle module, which is faster for such large objects.
 #
 
-if sys.platform != 'win32':
+import multiprocessing as mp
+if mp.get_start_method() != "spawn":
     def func_async(i, *args):
         return 2 * i
 
diff --git a/joblib/_parallel_config.py.bck b/joblib/_parallel_config.py.bck
deleted file mode 100644
index 01ad6a4..0000000
--- a/joblib/_parallel_config.py.bck
+++ /dev/null
@@ -1,542 +0,0 @@
-
-import warnings
-import threading
-
-from ._utils import _Sentinel
-from ._multiprocessing_helpers import mp
-
-from ._parallel_backends import LokyBackend
-from ._parallel_backends import ThreadingBackend
-from ._parallel_backends import SequentialBackend
-from ._parallel_backends import MultiprocessingBackend
-
-BACKENDS = {
-    'threading': ThreadingBackend,
-    'sequential': SequentialBackend,
-}
-# name of the backend used by default by Parallel outside of any context
-# managed by ``parallel_config`` or ``parallel_backend``.
-
-# threading is the only backend that is always everywhere
-DEFAULT_BACKEND = 'threading'
-
-MAYBE_AVAILABLE_BACKENDS = {'multiprocessing', 'loky'}
-
-# if multiprocessing is available, so is loky, we set it as the default
-# backend
-if mp is not None:
-    BACKENDS['multiprocessing'] = MultiprocessingBackend
-    BACKENDS['loky'] = LokyBackend
-    DEFAULT_BACKEND = 'loky'
-
-
-DEFAULT_THREAD_BACKEND = 'threading'
-
-
-###############################################################################
-def register_parallel_backend(name, factory, make_default=False):
-    """Register a new Parallel backend factory.
-
-    The new backend can then be selected by passing its name as the backend
-    argument to the :class:`~Parallel` class. Moreover, the default backend can
-    be overwritten globally by setting make_default=True.
-
-    The factory can be any callable that takes no argument and return an
-    instance of ``ParallelBackendBase``.
-
-    Warning: this function is experimental and subject to change in a future
-    version of joblib.
-
-    .. versionadded:: 0.10
-    """
-    BACKENDS[name] = factory
-    if make_default:
-        global DEFAULT_BACKEND
-        DEFAULT_BACKEND = name
-
-
-def _register_dask():
-    """Register Dask Backend if called with parallel_config(backend="dask")"""
-    try:
-        from ._dask import DaskDistributedBackend
-        register_parallel_backend('dask', DaskDistributedBackend)
-    except ImportError as e:
-        msg = ("To use the dask.distributed backend you must install both "
-               "the `dask` and distributed modules.\n\n"
-               "See https://dask.pydata.org/en/latest/install.html for more "
-               "information.")
-        raise ImportError(msg) from e
-
-
-EXTERNAL_BACKENDS = {
-    'dask': _register_dask,
-}
-
-VALID_BACKEND_HINTS = ('processes', 'threads', None)
-VALID_BACKEND_CONSTRAINTS = ('sharedmem', None)
-
-
-# Thread local value that can be overridden by the ``parallel_config`` context
-# manager
-_backend = threading.local()
-
-# Sentinels for the default values of the Parallel constructor and
-# the parallel_config and parallel_backend context managers
-default_parallel_config = {
-    "backend": _Sentinel(default_value=None),
-    "n_jobs": _Sentinel(default_value=None),
-    "verbose": _Sentinel(default_value=0),
-    "temp_folder": _Sentinel(default_value=None),
-    "max_nbytes": _Sentinel(default_value="1M"),
-    "mmap_mode": _Sentinel(default_value="r"),
-    "prefer": _Sentinel(default_value=None),
-    "require": _Sentinel(default_value=None),
-    "_parent_config": None,
-}
-
-
-def _get_config_param(param, context_config, key):
-    """Return the value of a parallel config parameter
-
-    Explicitly setting it in Parallel has priority over setting in a
-    parallel_(config/backend) context manager.
-    """
-    if param is not default_parallel_config[key]:
-        # param is explicitely set, return it
-        return param
-
-    if (context_config is not None and key in context_config
-            and context_config[key] is not default_parallel_config[key]):
-        # there's a context manager and the key is set, return it
-        return context_config[key]
-
-    # There is either no context manager or a context manager with the key
-    # not set. Just return the value from the parent_config if it exists.
-    if context_config['_parent_config'] is not None:
-        return _get_config_param(param, context_config['_parent_config'], key)
-
-    # Otherwise, we are in the default_parallel_config,
-    # return the default value
-    return param.default_value
-
-
-def get_active_backend(
-    prefer=default_parallel_config["prefer"],
-    require=default_parallel_config["require"],
-    verbose=default_parallel_config["verbose"],
-):
-    """Return the active default backend"""
-    backend, config = _get_active_backend(prefer, require, verbose)
-    n_jobs = _get_config_param(
-        default_parallel_config['n_jobs'], config, "n_jobs"
-    )
-    return backend, n_jobs
-
-
-def _get_active_backend(
-    prefer=default_parallel_config["prefer"],
-    require=default_parallel_config["require"],
-    verbose=default_parallel_config["verbose"],
-):
-    """Return the active default backend"""
-
-    backend_config = getattr(_backend, "config", default_parallel_config)
-
-    backend = _get_config_param(
-        default_parallel_config['backend'], backend_config, "backend"
-    )
-    prefer = _get_config_param(prefer, backend_config, "prefer")
-    require = _get_config_param(require, backend_config, "require")
-    verbose = _get_config_param(verbose, backend_config, "verbose")
-
-    if prefer not in VALID_BACKEND_HINTS:
-        raise ValueError(
-            f"prefer={prefer} is not a valid backend hint, "
-            f"expected one of {VALID_BACKEND_HINTS}"
-        )
-    if require not in VALID_BACKEND_CONSTRAINTS:
-        raise ValueError(
-            f"require={require} is not a valid backend constraint, "
-            f"expected one of {VALID_BACKEND_CONSTRAINTS}"
-        )
-    if prefer == 'processes' and require == 'sharedmem':
-        raise ValueError(
-            "prefer == 'processes' and require == 'sharedmem'"
-            " are inconsistent settings"
-        )
-
-    explicit_backend = True
-    if backend is None:
-
-        # We are either outside of the scope of any parallel_(config/backend)
-        # context manager or the context manager did not set a backend.
-        # create the default backend instance now.
-        backend = BACKENDS[DEFAULT_BACKEND](nesting_level=0)
-        explicit_backend = False
-
-    # Try to use the backend set by the user with the context manager.
-
-    nesting_level = backend.nesting_level
-    uses_threads = getattr(backend, 'uses_threads', False)
-    supports_sharedmem = getattr(backend, 'supports_sharedmem', False)
-    # Force to use thread-based backend if the provided backend does not
-    # match the shared memory constraint or if the backend is not explicitely
-    # given and threads are prefered.
-    force_threads = (require == 'sharedmem' and not supports_sharedmem)
-    force_threads |= (
-        not explicit_backend and prefer == 'threads' and not uses_threads
-    )
-    if force_threads:
-        # This backend does not match the shared memory constraint:
-        # fallback to the default thead-based backend.
-        sharedmem_backend = BACKENDS[DEFAULT_THREAD_BACKEND](
-            nesting_level=nesting_level
-        )
-        # Warn the user if we forced the backend to thread-based, while the
-        # user explicitely specified a non-thread-based backend.
-        if verbose >= 10 and explicit_backend:
-            print(
-                f"Using {sharedmem_backend.__class__.__name__} as "
-                f"joblib backend instead of {backend.__class__.__name__} "
-                "as the latter does not provide shared memory semantics."
-            )
-        return sharedmem_backend, {
-            'n_jobs': 1, '_parent_config': backend_config
-        }
-
-    return backend, backend_config
-
-
-class parallel_config:
-    """Set the default backend or configuration for :class:`~joblib.Parallel`.
-
-    This is an alternative to directly passing keyword arguments to the
-    :class:`~joblib.Parallel` class constructor. It is particularly useful when
-    calling into library code that uses joblib internally but does not expose
-    the various parallel configuration arguments in its own API.
-
-    Parameters
-    ----------
-    backend : str or ParallelBackendBase instance, default=None
-        If ``backend`` is a string it must match a previously registered
-        implementation using the :func:`~register_parallel_backend` function.
-
-        By default the following backends are available:
-
-        - 'loky': single-host, process-based parallelism (used by default),
-        - 'threading': single-host, thread-based parallelism,
-        - 'multiprocessing': legacy single-host, process-based parallelism.
-
-        'loky' is recommended to run functions that manipulate Python objects.
-        'threading' is a low-overhead alternative that is most efficient for
-        functions that release the Global Interpreter Lock: e.g. I/O-bound
-        code or CPU-bound code in a few calls to native code that explicitly
-        releases the GIL. Note that on some rare systems (such as pyodide),
-        multiprocessing and loky may not be available, in which case joblib
-        defaults to threading.
-
-        In addition, if the ``dask`` and ``distributed`` Python packages are
-        installed, it is possible to use the 'dask' backend for better
-        scheduling of nested parallel calls without over-subscription and
-        potentially distribute parallel calls over a networked cluster of
-        several hosts.
-
-        It is also possible to use the distributed 'ray' backend for
-        distributing the workload to a cluster of nodes. See more details
-        in the Examples section below.
-
-        Alternatively the backend can be passed directly as an instance.
-
-    n_jobs : int, default=None
-        The maximum number of concurrently running jobs, such as the number
-        of Python worker processes when ``backend="loky"`` or the size of the
-        thread-pool when ``backend="threading"``.
-        If -1 all CPUs are used. If 1 is given, no parallel computing code
-        is used at all, which is useful for debugging. For ``n_jobs`` below -1,
-        (n_cpus + 1 + n_jobs) are used. Thus for ``n_jobs=-2``, all
-        CPUs but one are used.
-        ``None`` is a marker for 'unset' that will be interpreted as
-        ``n_jobs=1`` in most backends.
-
-    verbose : int, default=0
-        The verbosity level: if non zero, progress messages are
-        printed. Above 50, the output is sent to stdout.
-        The frequency of the messages increases with the verbosity level.
-        If it more than 10, all iterations are reported.
-
-    temp_folder : str, default=None
-        Folder to be used by the pool for memmapping large arrays
-        for sharing memory with worker processes. If None, this will try in
-        order:
-
-        - a folder pointed by the ``JOBLIB_TEMP_FOLDER`` environment
-          variable,
-        - ``/dev/shm`` if the folder exists and is writable: this is a
-          RAM disk filesystem available by default on modern Linux
-          distributions,
-        - the default system temporary folder that can be
-          overridden with ``TMP``, ``TMPDIR`` or ``TEMP`` environment
-          variables, typically ``/tmp`` under Unix operating systems.
-
-    max_nbytes int, str, or None, optional, default='1M'
-        Threshold on the size of arrays passed to the workers that
-        triggers automated memory mapping in temp_folder. Can be an int
-        in Bytes, or a human-readable string, e.g., '1M' for 1 megabyte.
-        Use None to disable memmapping of large arrays.
-
-    mmap_mode: {None, 'r+', 'r', 'w+', 'c'}, default='r'
-        Memmapping mode for numpy arrays passed to workers. None will
-        disable memmapping, other modes defined in the numpy.memmap doc:
-        https://numpy.org/doc/stable/reference/generated/numpy.memmap.html
-        Also, see 'max_nbytes' parameter documentation for more details.
-
-    prefer: str in {'processes', 'threads'} or None, default=None
-        Soft hint to choose the default backend.
-        The default process-based backend is 'loky' and the default
-        thread-based backend is 'threading'. Ignored if the ``backend``
-        parameter is specified.
-
-    require: 'sharedmem' or None, default=None
-        Hard constraint to select the backend. If set to 'sharedmem',
-        the selected backend will be single-host and thread-based.
-
-    inner_max_num_threads : int, default=None
-        If not None, overwrites the limit set on the number of threads
-        usable in some third-party library threadpools like OpenBLAS,
-        MKL or OpenMP. This is only used with the ``loky`` backend.
-
-    backend_params : dict
-        Additional parameters to pass to the backend constructor when
-        backend is a string.
-
-    Notes
-    -----
-    Joblib tries to limit the oversubscription by limiting the number of
-    threads usable in some third-party library threadpools like OpenBLAS, MKL
-    or OpenMP. The default limit in each worker is set to
-    ``max(cpu_count() // effective_n_jobs, 1)`` but this limit can be
-    overwritten with the ``inner_max_num_threads`` argument which will be used
-    to set this limit in the child processes.
-
-    .. versionadded:: 1.3
-
-    Examples
-    --------
-    >>> from operator import neg
-    >>> with parallel_config(backend='threading'):
-    ...     print(Parallel()(delayed(neg)(i + 1) for i in range(5)))
-    ...
-    [-1, -2, -3, -4, -5]
-
-    To use the 'ray' joblib backend add the following lines:
-
-    >>> from ray.util.joblib import register_ray  # doctest: +SKIP
-    >>> register_ray()  # doctest: +SKIP
-    >>> with parallel_config(backend="ray"):  # doctest: +SKIP
-    ...     print(Parallel()(delayed(neg)(i + 1) for i in range(5)))
-    [-1, -2, -3, -4, -5]
-
-    """
-    def __init__(
-        self,
-        backend=default_parallel_config["backend"],
-        *,
-        n_jobs=default_parallel_config["n_jobs"],
-        verbose=default_parallel_config["verbose"],
-        temp_folder=default_parallel_config["temp_folder"],
-        max_nbytes=default_parallel_config["max_nbytes"],
-        mmap_mode=default_parallel_config["mmap_mode"],
-        prefer=default_parallel_config["prefer"],
-        require=default_parallel_config["require"],
-        inner_max_num_threads=None,
-        **backend_params
-    ):
-        # Save the parallel info and set the active parallel config
-        old_parallel_config = getattr(
-            _backend, "config", default_parallel_config
-        )
-
-        self.parallel_config = {
-            "n_jobs": n_jobs,
-            "verbose": verbose,
-            "temp_folder": temp_folder,
-            "max_nbytes": max_nbytes,
-            "mmap_mode": mmap_mode,
-            "prefer": prefer,
-            "require": require,
-            "_parent_config": old_parallel_config,
-        }
-
-        backend = self._check_backend(
-            backend, inner_max_num_threads, **backend_params
-        )
-
-        self.parallel_config["backend"] = backend
-        setattr(_backend, "config", self.parallel_config)
-
-    def _check_backend(self, backend, inner_max_num_threads, **backend_params):
-        if backend is default_parallel_config['backend']:
-            if inner_max_num_threads is not None or len(backend_params) > 0:
-                raise ValueError(
-                    "inner_max_num_threads and other constructor "
-                    "parameters backend_params are only supported "
-                    "when backend is not None."
-                )
-            return backend
-
-        if isinstance(backend, str):
-            # Handle non-registered or missing backends
-            if backend not in BACKENDS:
-                if backend in EXTERNAL_BACKENDS:
-                    register = EXTERNAL_BACKENDS[backend]
-                    register()
-                elif backend in MAYBE_AVAILABLE_BACKENDS:
-                    warnings.warn(
-                        f"joblib backend '{backend}' is not available on "
-                        f"your system, falling back to {DEFAULT_BACKEND}.",
-                        UserWarning,
-                        stacklevel=2
-                    )
-                    BACKENDS[backend] = BACKENDS[DEFAULT_BACKEND]
-                else:
-                    raise ValueError(
-                        f"Invalid backend: {backend}, expected one of "
-                        f"{sorted(BACKENDS.keys())}"
-                    )
-
-            backend = BACKENDS[backend](**backend_params)
-
-        if inner_max_num_threads is not None:
-            msg = (
-                f"{backend.__class__.__name__} does not accept setting the "
-                "inner_max_num_threads argument."
-            )
-            assert backend.supports_inner_max_num_threads, msg
-            backend.inner_max_num_threads = inner_max_num_threads
-
-        # If the nesting_level of the backend is not set previously, use the
-        # nesting level from the previous active_backend to set it
-        if backend.nesting_level is None:
-            parent_backend = self.parallel_config['_parent_config']['backend']
-            if parent_backend is default_parallel_config['backend']:
-                nesting_level = 0
-            else:
-                nesting_level = parent_backend.nesting_level
-            backend.nesting_level = nesting_level
-
-        return backend
-
-    def __enter__(self):
-        return self.parallel_config
-
-    def __exit__(self, type, value, traceback):
-        self.unregister()
-
-    def unregister(self):
-        setattr(_backend, "config", self.parallel_config['_parent_config'])
-
-
-class parallel_backend(parallel_config):
-    """Change the default backend used by Parallel inside a with block.
-
-    It is advised to use the :class:`~joblib.parallel.parallel_config` context
-    manager instead, which allows more fine-grained control over the backend
-    configuration.
-
-    If ``backend`` is a string it must match a previously registered
-    implementation using the :func:`~register_parallel_backend` function.
-
-    By default the following backends are available:
-
-    - 'loky': single-host, process-based parallelism (used by default),
-    - 'threading': single-host, thread-based parallelism,
-    - 'multiprocessing': legacy single-host, process-based parallelism.
-
-    'loky' is recommended to run functions that manipulate Python objects.
-    'threading' is a low-overhead alternative that is most efficient for
-    functions that release the Global Interpreter Lock: e.g. I/O-bound code or
-    CPU-bound code in a few calls to native code that explicitly releases the
-    GIL. Note that on some rare systems (such as Pyodide),
-    multiprocessing and loky may not be available, in which case joblib
-    defaults to threading.
-
-    You can also use the `Dask <https://docs.dask.org/en/stable/>`_ joblib
-    backend to distribute work across machines. This works well with
-    scikit-learn estimators with the ``n_jobs`` parameter, for example::
-
-    >>> import joblib  # doctest: +SKIP
-    >>> from sklearn.model_selection import GridSearchCV  # doctest: +SKIP
-    >>> from dask.distributed import Client, LocalCluster # doctest: +SKIP
-
-    >>> # create a local Dask cluster
-    >>> cluster = LocalCluster()  # doctest: +SKIP
-    >>> client = Client(cluster)  # doctest: +SKIP
-    >>> grid_search = GridSearchCV(estimator, param_grid, n_jobs=-1)
-    ... # doctest: +SKIP
-    >>> with joblib.parallel_backend("dask", scatter=[X, y]):  # doctest: +SKIP
-    ...     grid_search.fit(X, y)
-
-    It is also possible to use the distributed 'ray' backend for distributing
-    the workload to a cluster of nodes. To use the 'ray' joblib backend add
-    the following lines::
-
-     >>> from ray.util.joblib import register_ray  # doctest: +SKIP
-     >>> register_ray()  # doctest: +SKIP
-     >>> with parallel_backend("ray"):  # doctest: +SKIP
-     ...     print(Parallel()(delayed(neg)(i + 1) for i in range(5)))
-     [-1, -2, -3, -4, -5]
-
-    Alternatively the backend can be passed directly as an instance.
-
-    By default all available workers will be used (``n_jobs=-1``) unless the
-    caller passes an explicit value for the ``n_jobs`` parameter.
-
-    This is an alternative to passing a ``backend='backend_name'`` argument to
-    the :class:`~Parallel` class constructor. It is particularly useful when
-    calling into library code that uses joblib internally but does not expose
-    the backend argument in its own API.
-
-    >>> from operator import neg
-    >>> with parallel_backend('threading'):
-    ...     print(Parallel()(delayed(neg)(i + 1) for i in range(5)))
-    ...
-    [-1, -2, -3, -4, -5]
-
-    Joblib also tries to limit the oversubscription by limiting the number of
-    threads usable in some third-party library threadpools like OpenBLAS, MKL
-    or OpenMP. The default limit in each worker is set to
-    ``max(cpu_count() // effective_n_jobs, 1)`` but this limit can be
-    overwritten with the ``inner_max_num_threads`` argument which will be used
-    to set this limit in the child processes.
-
-    .. versionadded:: 0.10
-
-    See Also
-    --------
-    joblib.parallel.parallel_config : context manager to change the backend
-                                      configuration
-    """
-    def __init__(self, backend, n_jobs=-1, inner_max_num_threads=None,
-                 **backend_params):
-
-        super().__init__(
-            backend=backend,
-            n_jobs=n_jobs,
-            inner_max_num_threads=inner_max_num_threads,
-            **backend_params
-        )
-
-        if self.parallel_config['_parent_config'] is None:
-            self.old_backend_and_jobs = None
-        else:
-            self.old_backend_and_jobs = (
-                self.parallel_config['_parent_config']["backend"],
-                self.parallel_config['_parent_config']["n_jobs"],
-            )
-        self.new_backend_and_jobs = (
-            self.parallel_config["backend"],
-            self.parallel_config["n_jobs"],
-        )
-
-    def __enter__(self):
-        return self.new_backend_and_jobs
diff --git a/joblib/parallel.py b/joblib/parallel.py
index dd7d91e..41a052b 100644
--- a/joblib/parallel.py
+++ b/joblib/parallel.py
@@ -450,9 +450,10 @@ class parallel_config:
 class parallel_backend(parallel_config):
     """Change the default backend used by Parallel inside a with block.
 
-    It is advised to use the :class:`~joblib.parallel_config` context manager
-    instead, which allows more fine-grained control over the backend
-    configuration.
+    .. warning::
+        It is advised to use the :class:`~joblib.parallel_config` context
+        manager instead, which allows more fine-grained control over the
+        backend configuration.
 
     If ``backend`` is a string it must match a previously registered
     implementation using the :func:`~register_parallel_backend` function.
@@ -1187,6 +1188,10 @@ class Parallel(Logger):
         prefer=default_parallel_config["prefer"],
         require=default_parallel_config["require"],
     ):
+        # Interpret n_jobs=None as 'unset'
+        if n_jobs is None:
+            n_jobs = default_parallel_config["n_jobs"]
+
         active_backend, context_config = _get_active_backend(
             prefer=prefer, require=require, verbose=verbose
         )


Test patch:
diff --git a/joblib/test/test_config.py b/joblib/test/test_config.py
index e7f6fb6..56eeed3 100644
--- a/joblib/test/test_config.py
+++ b/joblib/test/test_config.py
@@ -123,3 +123,29 @@ def test_threadpool_limitation_in_child_context_error(context, backend):
 
     with raises(AssertionError, match=r"does not acc.*inner_max_num_threads"):
         context(backend, inner_max_num_threads=1)
+
+
+@parametrize("context", [parallel_config, parallel_backend])
+def test_parallel_n_jobs_none(context):
+    # Check that n_jobs=None is interpreted as "unset" in Parallel
+    # non regression test for #1473
+    with context(backend="threading", n_jobs=2):
+        with Parallel(n_jobs=None) as p:
+            assert p.n_jobs == 2
+
+    with context(backend="threading"):
+        default_n_jobs = Parallel().n_jobs
+        with Parallel(n_jobs=None) as p:
+            assert p.n_jobs == default_n_jobs
+
+
+@parametrize("context", [parallel_config, parallel_backend])
+def test_parallel_config_n_jobs_none(context):
+    # Check that n_jobs=None is interpreted as "explicitly set" in
+    # parallel_(config/backend)
+    # non regression test for #1473
+    with context(backend="threading", n_jobs=2):
+        with context(backend="threading", n_jobs=None):
+            # n_jobs=None resets n_jobs to backend's default
+            with Parallel() as p:
+                assert p.n_jobs == 1