Repository: sdv-dev/SDV

Issue:
If I don't have `torch` installed, I should still be able to use SDV features that don't require torch
### Problem Description
For my usage, I'd like to install SDV for creating synthetic data with GaussianCopula. For various reasons (storage size, installation time, IT requirements, etc.), I do not want to install the `torch` library; I won't need it anyways because GaussianCopula is a statistical model that doesn't need GAN-related code.

Now, the official SDV package _does_ list `torch` as a dependency -- but I can override this by asking `pip` to ignore dependencies and only install the packages that I require. For example, see my `requirements-min.txt` file below that lists the requirements that I want installed. It has all SDV-related libraries _except_ torch.

[requirements-min.txt](https://github.com/user-attachments/files/20418275/requirements-min.txt)

```bash
pip install sdv --no-deps --requirement requirements-min.txt
```

This successfully installs the packages. However, I'm unable to actually _use_ any of the features in SDV because the import of `torch` is failing. This is not ideal, because I do not need `torch` for the feature I'm trying to use.

```python
from sdv.single_table import GaussianCopulaSynthesizer
```

```
  File "/env/lib/python3.12/site-packages/ctgan/synthesizers/ctgan.py", line 7, in <module>
    import torch
ModuleNotFoundError: No module named 'torch'
```

### Expected behavior
In this case, I would expect to be able to use `GaussianCopulaSynthesizer`, as well as all other features that do NOT require `torch`. I would only expect an error only if I tried to use features that require torch -- eg. `CTGANSynthesizer, TVAESynthesizer, PARSynthesizer`.

This can be achieved by adding a try/catch whenever we import a torch-related library within SDV. (eg. when we import [the CTGAN and TVAE classes](https://github.com/sdv-dev/SDV/blob/main/sdv/single_table/ctgan.py#L8)). The error can be thrown later on if I'm trying to use one of the relevant synthesizers.

Ideally the error can be very easy to follow. Eg.
```python
# GaussianCopula works just fine bc it does not use torch
from sdv.single_table import GaussianCopulaSynthesizer
synthesizer = GaussianCopulaSynthesizer(metadata)
synthesizer.fit(data)
...

# CTGANSynthesizer uses torch so this would create an error
from sdv.single_table import CTGANSynthesizer
synthesizer = CTGANSynthesizer(metadata)
```
```
ModuleNotFoundError: No module named 'torch'. Please install torch in order to use the 'CTGANSynthesizer'.
```

### Additional context
This feedback has been provided to us by many users. See #2173.


Discussion:

Patch:
diff --git a/sdv/sequential/par.py b/sdv/sequential/par.py
index 16a3da005..7c9bcbfc4 100644
--- a/sdv/sequential/par.py
+++ b/sdv/sequential/par.py
@@ -9,8 +9,6 @@
 import numpy as np
 import pandas as pd
 import tqdm
-from deepecho import PARModel
-from deepecho.sequences import assemble_sequences
 from rdt.transformers import FloatFormatter
 
 from sdv._utils import _cast_to_iterable, _groupby_list
@@ -24,11 +22,22 @@
 from sdv.single_table import GaussianCopulaSynthesizer
 from sdv.single_table.base import BaseSynthesizer
 from sdv.single_table.ctgan import LossValuesMixin
+from sdv.utils.mixins import MissingModuleMixin
+
+try:
+    from deepecho import PARModel
+    from deepecho.sequences import assemble_sequences
+
+    import_error = None
+except ModuleNotFoundError as e:
+    PARModel = None
+    assemble_sequences = None
+    import_error = e
 
 LOGGER = logging.getLogger(__name__)
 
 
-class PARSynthesizer(LossValuesMixin, BaseSynthesizer):
+class PARSynthesizer(LossValuesMixin, MissingModuleMixin, BaseSynthesizer):
     """Synthesizer for sequential data.
 
     This synthesizer uses the ``deepecho.models.par.PARModel`` class as the core model.
@@ -132,6 +141,8 @@ def __init__(
         cuda=True,
         verbose=False,
     ):
+        if PARModel is None:
+            self.raise_module_not_found_error(import_error)
         if type(metadata) is Metadata and len(metadata.tables) > 1:
             raise InvalidMetadataError('PARSynthesizer can only be used with a single table.')
 
diff --git a/sdv/single_table/ctgan.py b/sdv/single_table/ctgan.py
index 80df4a7ba..da53ee6f4 100644
--- a/sdv/single_table/ctgan.py
+++ b/sdv/single_table/ctgan.py
@@ -5,12 +5,21 @@
 import numpy as np
 import pandas as pd
 import plotly.express as px
-from ctgan import CTGAN, TVAE
 from sdmetrics import visualization
 
 from sdv.errors import InvalidDataTypeError, NotFittedError
 from sdv.single_table.base import BaseSingleTableSynthesizer
 from sdv.single_table.utils import detect_discrete_columns
+from sdv.utils.mixins import MissingModuleMixin
+
+try:
+    from ctgan import CTGAN, TVAE
+
+    import_error = None
+except ModuleNotFoundError as e:
+    CTGAN = None
+    TVAE = None
+    import_error = e
 
 
 def _validate_no_category_dtype(data):
@@ -96,7 +105,7 @@ def get_loss_values_plot(self, title='CTGAN loss function'):
         return fig
 
 
-class CTGANSynthesizer(LossValuesMixin, BaseSingleTableSynthesizer):
+class CTGANSynthesizer(LossValuesMixin, MissingModuleMixin, BaseSingleTableSynthesizer):
     """Model wrapping ``CTGAN`` model.
 
     Args:
@@ -173,6 +182,8 @@ def __init__(
         pac=10,
         cuda=True,
     ):
+        if CTGAN is None:
+            self.raise_module_not_found_error(import_error)
         super().__init__(
             metadata=metadata,
             enforce_min_max_values=enforce_min_max_values,
@@ -312,7 +323,7 @@ def _sample(self, num_rows, conditions=None):
         raise NotImplementedError("CTGANSynthesizer doesn't support conditional sampling.")
 
 
-class TVAESynthesizer(LossValuesMixin, BaseSingleTableSynthesizer):
+class TVAESynthesizer(LossValuesMixin, MissingModuleMixin, BaseSingleTableSynthesizer):
     """Model wrapping ``TVAE`` model.
 
     Args:
@@ -364,6 +375,8 @@ def __init__(
         loss_factor=2,
         cuda=True,
     ):
+        if TVAE is None:
+            self.raise_module_not_found_error(import_error)
         super().__init__(
             metadata=metadata,
             enforce_min_max_values=enforce_min_max_values,
diff --git a/sdv/utils/mixins.py b/sdv/utils/mixins.py
new file mode 100644
index 000000000..ca73336c4
--- /dev/null
+++ b/sdv/utils/mixins.py
@@ -0,0 +1,12 @@
+"""Mixin classes for general use."""
+
+
+class MissingModuleMixin:
+    """Mixin for raising a custom error message when a module is not found."""
+
+    @classmethod
+    def raise_module_not_found_error(cls, error):
+        """Takes in an existing ModuleNotFoundError and raises a new one with custom text."""
+        raise ModuleNotFoundError(
+            f"{error.msg}. Please install {error.name} in order to use the '{cls.__name__}'."
+        )


Test patch:
diff --git a/tests/integration/sequential/test_par.py b/tests/integration/sequential/test_par.py
index 5022b5c0d..3322e6a4b 100644
--- a/tests/integration/sequential/test_par.py
+++ b/tests/integration/sequential/test_par.py
@@ -1,5 +1,6 @@
 import datetime
 import re
+from unittest.mock import patch
 
 import numpy as np
 import pandas as pd
@@ -518,3 +519,18 @@ def test_par_categorical_column_updated_to_float():
 
     # Assert
     assert sampled['column'].isin(data['column']).all()
+
+
+@patch('sdv.sequential.par.PARModel', None)
+@patch('sdv.sequential.par.import_error')
+def test___init___without_torch(mock_import_error):
+    """Test PAR raises a custom error when initialized with torch not installed."""
+    # Setup
+    _, metadata = _get_par_data_and_metadata()
+    mock_import_error.name = 'torch'
+    mock_import_error.msg = "No module named 'torch'"
+    msg = "No module named 'torch'. Please install torch in order to use the 'PARSynthesizer'."
+
+    # Run and Assert
+    with pytest.raises(ModuleNotFoundError, match=msg):
+        PARSynthesizer(metadata)
diff --git a/tests/integration/single_table/test_ctgan.py b/tests/integration/single_table/test_ctgan.py
index f347312a5..2554e9a2c 100644
--- a/tests/integration/single_table/test_ctgan.py
+++ b/tests/integration/single_table/test_ctgan.py
@@ -1,4 +1,5 @@
 import re
+from unittest.mock import patch
 
 import numpy as np
 import pandas as pd
@@ -50,6 +51,21 @@ def test__estimate_num_columns():
     }
 
 
+@patch('sdv.single_table.ctgan.CTGAN', None)
+@patch('sdv.single_table.ctgan.import_error')
+def test_ctgan___init___without_torch(mock_import_error):
+    """Test CTGAN raises a custom error when initialized with torch not installed."""
+    # Setup
+    metadata = Metadata()
+    mock_import_error.name = 'torch'
+    mock_import_error.msg = "No module named 'torch'"
+    msg = "No module named 'torch'. Please install torch in order to use the 'CTGANSynthesizer'."
+
+    # Run and Assert
+    with pytest.raises(ModuleNotFoundError, match=msg):
+        CTGANSynthesizer(metadata)
+
+
 def test_synthesize_table_ctgan(tmp_path):
     """End to end test for the CTGAN synthesizer.
 
@@ -300,3 +316,18 @@ def test_ctgan_with_dropped_columns():
             name='user_id',
         ),
     )
+
+
+@patch('sdv.single_table.ctgan.TVAE', None)
+@patch('sdv.single_table.ctgan.import_error')
+def test_tvae___init___without_torch(mock_import_error):
+    """Test TVAE raises a custom error when initialized with torch not installed."""
+    # Setup
+    metadata = Metadata()
+    mock_import_error.name = 'torch'
+    mock_import_error.msg = "No module named 'torch'"
+    msg = "No module named 'torch'. Please install torch in order to use the 'TVAESynthesizer'."
+
+    # Run and Assert
+    with pytest.raises(ModuleNotFoundError, match=msg):
+        TVAESynthesizer(metadata)
diff --git a/tests/unit/sequential/test_par.py b/tests/unit/sequential/test_par.py
index 6f74889de..9de2ebd54 100644
--- a/tests/unit/sequential/test_par.py
+++ b/tests/unit/sequential/test_par.py
@@ -115,6 +115,20 @@ def test___init___no_sequence_key(self):
                 verbose=False,
             )
 
+    @patch('sdv.sequential.par.PARModel', None)
+    @patch('sdv.sequential.par.import_error')
+    @patch('sdv.sequential.par.PARSynthesizer.raise_module_not_found_error')
+    def test___init___no_torch(self, mock_raise_error, mock_import_error):
+        """Test PAR raises a custom error when initialized with torch not installed."""
+        # Setup
+        metadata = self.get_metadata()
+
+        # Run
+        PARSynthesizer(metadata)
+
+        # Assert
+        mock_raise_error.assert_called_once_with(mock_import_error)
+
     @patch.object(BaseSynthesizer, 'add_constraints')
     def test_add_constraints(self, add_constraints_mock):
         """Test that that only simple constraints can be added to PARSynthesizer."""
diff --git a/tests/unit/single_table/test_ctgan.py b/tests/unit/single_table/test_ctgan.py
index 599a5e4c7..c68902ab6 100644
--- a/tests/unit/single_table/test_ctgan.py
+++ b/tests/unit/single_table/test_ctgan.py
@@ -154,6 +154,20 @@ def test___init__custom(self):
         assert instance.pac == pac
         assert instance.cuda is False
 
+    @patch('sdv.single_table.ctgan.CTGAN', None)
+    @patch('sdv.single_table.ctgan.import_error')
+    @patch('sdv.single_table.ctgan.CTGANSynthesizer.raise_module_not_found_error')
+    def test___init___no_torch(self, mock_raise_error, mock_import_error):
+        """Test CTGAN raises a custom error when initialized with torch not installed."""
+        # Setup
+        metadata = Metadata()
+
+        # Run
+        CTGANSynthesizer(metadata)
+
+        # Assert
+        mock_raise_error.assert_called_once_with(mock_import_error)
+
     def test_get_parameters(self):
         """Test that inherited method ``get_parameters`` returns the specific init parameters."""
         # Setup
@@ -441,6 +455,20 @@ def test___init__custom(self):
         assert instance.loss_factor == 4
         assert instance.cuda is False
 
+    @patch('sdv.single_table.ctgan.TVAE', None)
+    @patch('sdv.single_table.ctgan.import_error')
+    @patch('sdv.single_table.ctgan.TVAESynthesizer.raise_module_not_found_error')
+    def test___init___no_torch(self, mock_raise_error, mock_import_error):
+        """Test TVAE raises a custom error when initialized with torch not installed."""
+        # Setup
+        metadata = Metadata()
+
+        # Run
+        TVAESynthesizer(metadata)
+
+        # Assert
+        mock_raise_error.assert_called_once_with(mock_import_error)
+
     def test_get_parameters(self):
         """Test that inherited method ``get_parameters`` returns the specific init parameters."""
         # Setup