Repository: pymc-devs/pymc

Issue:
Implement `__copy__` and `__deepcopy__` model methods
### Description

A couple of users on discourse got some nasty bugs when trying to do `copy.deepcopy(model)`. Whatever the default is doing, it ain't great.

We have a `clone_model` in `model/fgraph` that we can use for both copy and deepcopy!

It should work in all cases except for the known issue with GP variables https://github.com/pymc-devs/pymc/issues/6883

Discussion:
welcome[bot]: ![Thank You Banner](https://raw.githubusercontent.com/pymc-devs/brand/main/welcome-bot/BannerThanks.jpg)]
:sparkling_heart: Thanks for opening this pull request! :sparkling_heart: The _PyMC_ community really appreciates your time and effort to contribute to the project. Please make sure you have read our [Contributing Guidelines](https://www.pymc.io/projects/docs/en/latest/contributing/index.html) and filled in our pull request template to the best of your ability.

Dekermanjian: Hey @ricardoV94, sorry if this is a silly question. Do I need to do anything to get the `all_tests` and `mypy` to start running?
ricardoV94: CI has to be manually approved for first time contributors, doing it now
Dekermanjian: Okay! Thank you very much!!
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pymc-devs/pymc/pull/7492?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pymc-devs) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.17%. Comparing base [(`d596afb`)](https://app.codecov.io/gh/pymc-devs/pymc/commit/d596afbe266ae1bdafec6735d73a82885e06dea1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pymc-devs) to head [(`14633bc`)](https://app.codecov.io/gh/pymc-devs/pymc/commit/14633bcf3f5343a625a71897c043d96c1a0a018a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pymc-devs).

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


[![Impacted file tree graph](https://app.codecov.io/gh/pymc-devs/pymc/pull/7492/graphs/tree.svg?width=650&height=150&src=pr&token=JFuXtOJ4Cb&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pymc-devs)](https://app.codecov.io/gh/pymc-devs/pymc/pull/7492?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pymc-devs)

```diff
@@           Coverage Diff           @@
##             main    #7492   +/-   ##
=======================================
  Coverage   92.16%   92.17%           
=======================================
  Files         103      103           
  Lines       17200    17212   +12     
=======================================
+ Hits        15853    15865   +12     
  Misses       1347     1347           
```

| [Files with missing lines](https://app.codecov.io/gh/pymc-devs/pymc/pull/7492?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pymc-devs) | Coverage Δ | |
|---|---|---|
| [pymc/model/core.py](https://app.codecov.io/gh/pymc-devs/pymc/pull/7492?src=pr&el=tree&filepath=pymc%2Fmodel%2Fcore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pymc-devs#diff-cHltYy9tb2RlbC9jb3JlLnB5) | `91.89% <100.00%> (+0.12%)` | :arrow_up: |

</details>
ricardoV94: @Dekermanjian looks great! I left some suggestions, let me know if something does not make sense
Dekermanjian: Thank you @ricardoV94 for your feedback! I appreciate it!
Dekermanjian: Hey @ricardoV94, is there anything you'd like me to modify/add to this pull request?
Dekermanjian: Thank you @ricardoV94 for your help with this and for your patience. I really appreciate it.
Dekermanjian: Hey Ricardo, I made the suggested changes. The tests look a lot better now. Thank you for reviewing it!

Patch:
diff --git a/.github/workflows/devcontainer-docker-image.yml b/.github/workflows/devcontainer-docker-image.yml
index 06aa5163c..f650c97a3 100644
--- a/.github/workflows/devcontainer-docker-image.yml
+++ b/.github/workflows/devcontainer-docker-image.yml
@@ -23,7 +23,7 @@ jobs:
 
     steps:
     - name: Checkout source
-      uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
 
     - name: Setup Docker buildx
       uses: docker/setup-buildx-action@v3.4.0
@@ -46,7 +46,7 @@ jobs:
 
     - name: Build and push Docker image
       id: docker_build
-      uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85
+      uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960
       with:
         context: .
         file: scripts/dev.Dockerfile
diff --git a/.github/workflows/dispatched_pre-commit.yml b/.github/workflows/dispatched_pre-commit.yml
index 35a7dd948..aa15698ec 100644
--- a/.github/workflows/dispatched_pre-commit.yml
+++ b/.github/workflows/dispatched_pre-commit.yml
@@ -7,7 +7,7 @@ jobs:
   runPreCommit:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
         with:
           repository: ${{github.event.client_payload.pull_request.head.repo.full_name}}
           ref: ${{github.event.client_payload.pull_request.head.ref}}
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index c7b1867c0..335ce77aa 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout code
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
 
       - name: Login to Docker Hub
         uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
@@ -34,7 +34,7 @@ jobs:
               type=semver,pattern={{major}}.{{minor}}
 
       - name: Build and load image
-        uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85
+        uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960
         with:
           context: .
           file: scripts/Dockerfile
@@ -46,7 +46,7 @@ jobs:
            docker run --rm ${{ env.CONTAINER_NAME }} conda run -n pymc-dev python -c 'import pymc;print(pymc.__version__)'
 
       - name: Build and push
-        uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85
+        uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960
         with:
           context: .
           push: true
diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml
index 8d51c4b58..864faea59 100644
--- a/.github/workflows/mypy.yml
+++ b/.github/workflows/mypy.yml
@@ -12,7 +12,7 @@ jobs:
       run:
         shell: bash -l {0}
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
       - name: Cache conda
         uses: actions/cache@v4
         env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fdc2d845a..5ca602d2f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ jobs:
     env:
       PYPI_TOKEN: ${{ secrets.PYPI_TOKEN_PYMC }}
     steps:
-    - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+    - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
     - name: Set up Python
       uses: actions/setup-python@v5
       with:
@@ -26,10 +26,10 @@ jobs:
     #    pytest --cov=./pymc --cov-report term-missing pymc/
     - name: Install release tooling
       run: |
-        pip install twine wheel
+        pip install build twine
     - name: Build package
       run: |
-        python setup.py sdist bdist_wheel
+        python -m build
     - name: Check version number match
       run: |
         echo "GITHUB_REF: ${GITHUB_REF}"
diff --git a/pymc/model/core.py b/pymc/model/core.py
index 3e59e7231..41d4e0864 100644
--- a/pymc/model/core.py
+++ b/pymc/model/core.py
@@ -1577,6 +1577,41 @@ class Model(WithMemoization, metaclass=ContextMeta):
     def __contains__(self, key):
         return key in self.named_vars or self.name_for(key) in self.named_vars
 
+    def __copy__(self):
+        return self.copy()
+
+    def __deepcopy__(self, _):
+        return self.copy()
+
+    def copy(self):
+        """
+        Clone the model
+
+        To access variables in the cloned model use `cloned_model["var_name"]`.
+
+        Examples
+        --------
+        .. code-block:: python
+
+            import pymc as pm
+            import copy
+
+            with pm.Model() as m:
+                p = pm.Beta("p", 1, 1)
+                x = pm.Bernoulli("x", p=p, shape=(3,))
+
+            clone_m = copy.copy(m)
+
+            # Access cloned variables by name
+            clone_x = clone_m["x"]
+
+            # z will be part of clone_m but not m
+            z = pm.Deterministic("z", clone_x + 1)
+        """
+        from pymc.model.fgraph import clone_model
+
+        return clone_model(self)
+
     def replace_rvs_by_values(
         self,
         graphs: Sequence[TensorVariable],
diff --git a/pymc/model/fgraph.py b/pymc/model/fgraph.py
index b1d67fd07..8c37861c8 100644
--- a/pymc/model/fgraph.py
+++ b/pymc/model/fgraph.py
@@ -11,6 +11,8 @@
 #   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 #   See the License for the specific language governing permissions and
 #   limitations under the License.
+import warnings
+
 from copy import copy, deepcopy
 
 import pytensor
@@ -158,6 +160,14 @@ def fgraph_from_model(
             "Nested sub-models cannot be converted to fgraph. Convert the parent model instead"
         )
 
+    if any(
+        ("_rotated_" in var_name or "_hsgp_coeffs_" in var_name) for var_name in model.named_vars
+    ):
+        warnings.warn(
+            "Detected variables likely created by GP objects. Further use of these old GP objects should be avoided as it may reintroduce variables from the old model. See issue: https://github.com/pymc-devs/pymc/issues/6883",
+            UserWarning,
+        )
+
     # Collect PyTensor variables
     rvs_to_values = model.rvs_to_values
     rvs = list(rvs_to_values.keys())


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 9ac9eff14..94bbe300b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -24,7 +24,7 @@ jobs:
     outputs:
       changes: ${{ steps.changes.outputs.src }}
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
         with:
           fetch-depth: 0
       - uses: dorny/paths-filter@v3
@@ -132,7 +132,7 @@ jobs:
       run:
         shell: bash -l {0}
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
       - name: Cache conda
         uses: actions/cache@v4
         env:
@@ -208,7 +208,7 @@ jobs:
       run:
         shell: cmd
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
       - name: Cache conda
         uses: actions/cache@v4
         env:
@@ -292,7 +292,7 @@ jobs:
       run:
         shell: bash -l {0}
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
       - name: Cache conda
         uses: actions/cache@v4
         env:
@@ -361,7 +361,7 @@ jobs:
       run:
         shell: bash -l {0}
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
       - name: Cache conda
         uses: actions/cache@v4
         env:
@@ -430,7 +430,7 @@ jobs:
       run:
         shell: cmd
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
       - name: Cache conda
         uses: actions/cache@v4
         env:
diff --git a/tests/model/test_core.py b/tests/model/test_core.py
index 95a46873f..cea9edf64 100644
--- a/tests/model/test_core.py
+++ b/tests/model/test_core.py
@@ -11,6 +11,7 @@
 #   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 #   See the License for the specific language governing permissions and
 #   limitations under the License.
+import copy
 import pickle
 import threading
 import traceback
@@ -1761,3 +1762,48 @@ class TestModelGraphs:
                 figsize=None,
                 dpi=300,
             )
+
+
+class TestModelCopy:
+    @pytest.mark.parametrize("copy_method", (copy.copy, copy.deepcopy))
+    def test_copy_model(self, copy_method) -> None:
+        with pm.Model() as simple_model:
+            pm.Normal("y")
+
+        copy_simple_model = copy_method(simple_model)
+
+        with simple_model:
+            simple_model_prior_predictive = pm.sample_prior_predictive(samples=1, random_seed=42)
+
+        with copy_simple_model:
+            z = pm.Deterministic("z", copy_simple_model["y"] + 1)
+            copy_simple_model_prior_predictive = pm.sample_prior_predictive(
+                samples=1, random_seed=42
+            )
+
+        assert (
+            simple_model_prior_predictive["prior"]["y"].values
+            == copy_simple_model_prior_predictive["prior"]["y"].values
+        )
+
+        assert "z" in copy_simple_model.named_vars
+        assert "z" not in simple_model.named_vars
+        assert (
+            copy_simple_model_prior_predictive["prior"]["z"].values
+            == 1 + simple_model_prior_predictive["prior"]["y"].values
+        )
+
+    @pytest.mark.parametrize("copy_method", (copy.copy, copy.deepcopy))
+    def test_guassian_process_copy_failure(self, copy_method) -> None:
+        with pm.Model() as gaussian_process_model:
+            ell = pm.Gamma("ell", alpha=2, beta=1)
+            cov = 2 * pm.gp.cov.ExpQuad(1, ell)
+            gp = pm.gp.Latent(cov_func=cov)
+            f = gp.prior("f", X=np.arange(10)[:, None])
+            pm.Normal("y", f * 2)
+
+        with pytest.warns(
+            UserWarning,
+            match="Detected variables likely created by GP objects. Further use of these old GP objects should be avoided as it may reintroduce variables from the old model. See issue: https://github.com/pymc-devs/pymc/issues/6883",
+        ):
+            copy_method(gaussian_process_model)