Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions pandas/core/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -8707,6 +8707,126 @@ def rpow(
other, roperator.rpow, level=level, fill_value=fill_value, axis=axis
)

def safe_divide(
self,
other,
axis: Axis = "columns",
level=None,
fill_value=None,
zero_division="warn"
) -> DataFrame:
"""
Perform safe division that handles division by zero gracefully.

This method performs division while handling division by zero cases
without raising exceptions. It's particularly useful for data analysis
where division by zero is a common occurrence.

Parameters
----------
other : scalar, sequence, Series, or DataFrame
Object to divide with.
axis : {0 or 'index', 1 or 'columns'}, default 'columns'
Whether to compare by the index (0 or 'index') or columns (1 or 'columns').
level : int or label, default None
Broadcast across a level, matching Index values on the passed MultiIndex level.
fill_value : float or None, default None
Value to use for missing values. If specified, this value will be used
to fill missing values before performing the operation.
zero_division : {'warn', 'raise', 'ignore'}, default 'warn'
How to handle division by zero:
- 'warn': Issue a warning and return inf for division by zero
- 'raise': Raise an exception for division by zero
- 'ignore': Return inf for division by zero without warning

Returns
-------
DataFrame
Result of the safe division operation.

See Also
--------
DataFrame.truediv : Standard division operation.
DataFrame.div : Alias for truediv.

Examples
--------
>>> df = pd.DataFrame({'A': [1, 2, 0], 'B': [4, 5, 6]})
>>> other = pd.DataFrame({'A': [2, 0, 4], 'B': [1, 2, 3]})
>>> df.safe_divide(other)
A B
0 0.5 4.0
1 inf 2.5
2 0.0 2.0

>>> df.safe_divide(other, zero_division='ignore')
A B
0 0.5 4.0
1 inf 2.5
2 0.0 2.0

>>> df.safe_divide(2)
A B
0 0.5 2.0
1 1.0 2.5
2 0.0 3.0
"""
import warnings

# Handle zero_division parameter
if zero_division not in ['warn', 'raise', 'ignore']:
raise ValueError("zero_division must be one of 'warn', 'raise', or 'ignore'")

# Perform the division with error handling
with np.errstate(divide='ignore', invalid='ignore'):
result = self._flex_arith_method(
other, operator.truediv, level=level, fill_value=fill_value, axis=axis
)

# Handle division by zero cases
if zero_division == 'raise':
# Check for division by zero and raise if found
if isinstance(other, (DataFrame, Series)):
# For DataFrame/Series operations, check if any denominator is zero
if isinstance(other, DataFrame):
zero_mask = (other == 0) & (self != 0)
else: # Series
zero_mask = (other == 0) & (self != 0)

if zero_mask.any().any():
raise ZeroDivisionError("Division by zero encountered")
else:
# For scalar operations
if other == 0 and (self != 0).any().any():
raise ZeroDivisionError("Division by zero encountered")

elif zero_division == 'warn':
# Check for division by zero and warn if found
if isinstance(other, (DataFrame, Series)):
if isinstance(other, DataFrame):
zero_mask = (other == 0) & (self != 0)
else: # Series
zero_mask = (other == 0) & (self != 0)

if zero_mask.any().any():
warnings.warn(
"Division by zero encountered. Results will contain inf values.",
RuntimeWarning,
stacklevel=2
)
else:
if other == 0 and (self != 0).any().any():
warnings.warn(
"Division by zero encountered. Results will contain inf values.",
RuntimeWarning,
stacklevel=2
)

# For 'ignore' case, we don't need to do anything special
# The result already contains inf values where appropriate

return result

# ----------------------------------------------------------------------
# Combination-Related

Expand Down
111 changes: 111 additions & 0 deletions pandas/core/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -6598,6 +6598,117 @@ def rpow(self, other, level=None, fill_value=None, axis: Axis = 0) -> Series:
other, roperator.rpow, level=level, fill_value=fill_value, axis=axis
)

def safe_divide(
self,
other,
level=None,
fill_value=None,
axis: Axis = 0,
zero_division="warn"
) -> Series:
"""
Perform safe division that handles division by zero gracefully.

This method performs division while handling division by zero cases
without raising exceptions. It's particularly useful for data analysis
where division by zero is a common occurrence.

Parameters
----------
other : scalar, sequence, Series, or DataFrame
Object to divide with.
level : int or label, default None
Broadcast across a level, matching Index values on the passed MultiIndex level.
fill_value : float or None, default None
Value to use for missing values. If specified, this value will be used
to fill missing values before performing the operation.
axis : {0 or 'index'}, default 0
Unused. Parameter needed for compatibility with DataFrame.
zero_division : {'warn', 'raise', 'ignore'}, default 'warn'
How to handle division by zero:
- 'warn': Issue a warning and return inf for division by zero
- 'raise': Raise an exception for division by zero
- 'ignore': Return inf for division by zero without warning

Returns
-------
Series
Result of the safe division operation.

See Also
--------
Series.truediv : Standard division operation.
Series.div : Alias for truediv.

Examples
--------
>>> s = pd.Series([1, 2, 0])
>>> other = pd.Series([2, 0, 4])
>>> s.safe_divide(other)
0 0.5
1 inf
2 0.0
dtype: float64

>>> s.safe_divide(other, zero_division='ignore')
0 0.5
1 inf
2 0.0
dtype: float64

>>> s.safe_divide(2)
0 0.5
1 1.0
2 0.0
dtype: float64
"""
import warnings

# Handle zero_division parameter
if zero_division not in ['warn', 'raise', 'ignore']:
raise ValueError("zero_division must be one of 'warn', 'raise', or 'ignore'")

# Perform the division with error handling
with np.errstate(divide='ignore', invalid='ignore'):
result = self._flex_method(
other, operator.truediv, level=level, fill_value=fill_value, axis=axis
)

# Handle division by zero cases
if zero_division == 'raise':
# Check for division by zero and raise if found
if isinstance(other, Series):
zero_mask = (other == 0) & (self != 0)
if zero_mask.any():
raise ZeroDivisionError("Division by zero encountered")
else:
# For scalar operations
if other == 0 and (self != 0).any():
raise ZeroDivisionError("Division by zero encountered")

elif zero_division == 'warn':
# Check for division by zero and warn if found
if isinstance(other, Series):
zero_mask = (other == 0) & (self != 0)
if zero_mask.any():
warnings.warn(
"Division by zero encountered. Results will contain inf values.",
RuntimeWarning,
stacklevel=2
)
else:
if other == 0 and (self != 0).any():
warnings.warn(
"Division by zero encountered. Results will contain inf values.",
RuntimeWarning,
stacklevel=2
)

# For 'ignore' case, we don't need to do anything special
# The result already contains inf values where appropriate

return result

@Appender(ops.make_flex_doc("divmod", "series"))
def divmod(self, other, level=None, fill_value=None, axis: Axis = 0) -> Series:
return self._flex_method(
Expand Down
143 changes: 143 additions & 0 deletions pandas/tests/frame/test_arithmetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2192,3 +2192,146 @@ def test_mixed_col_index_dtype(string_dtype_no_object):
expected.columns = expected.columns.astype(string_dtype_no_object)

tm.assert_frame_equal(result, expected)


class TestDataFrameSafeDivide:
"""Test cases for DataFrame.safe_divide method."""

def test_safe_divide_basic(self):
"""Test basic safe division functionality."""
df = DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
other = DataFrame({'A': [2, 1, 3], 'B': [2, 2, 2]})

result = df.safe_divide(other)
expected = DataFrame({'A': [0.5, 2.0, 1.0], 'B': [2.0, 2.5, 3.0]})

tm.assert_frame_equal(result, expected)

def test_safe_divide_with_zero_division_warn(self):
"""Test safe division with zero division warning."""
df = DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
other = DataFrame({'A': [2, 0, 3], 'B': [2, 2, 2]})

with pytest.warns(RuntimeWarning, match="Division by zero encountered"):
result = df.safe_divide(other)

expected = DataFrame({'A': [0.5, np.inf, 1.0], 'B': [2.0, 2.5, 3.0]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_with_zero_division_raise(self):
"""Test safe division with zero division raising exception."""
df = DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
other = DataFrame({'A': [2, 0, 3], 'B': [2, 2, 2]})

with pytest.raises(ZeroDivisionError, match="Division by zero encountered"):
df.safe_divide(other, zero_division='raise')

def test_safe_divide_with_zero_division_ignore(self):
"""Test safe division with zero division ignored."""
df = DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
other = DataFrame({'A': [2, 0, 3], 'B': [2, 2, 2]})

result = df.safe_divide(other, zero_division='ignore')
expected = DataFrame({'A': [0.5, np.inf, 1.0], 'B': [2.0, 2.5, 3.0]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_with_scalar(self):
"""Test safe division with scalar values."""
df = DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})

result = df.safe_divide(2)
expected = DataFrame({'A': [0.5, 1.0, 1.5], 'B': [2.0, 2.5, 3.0]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_with_scalar_zero(self):
"""Test safe division with scalar zero."""
df = DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})

with pytest.warns(RuntimeWarning, match="Division by zero encountered"):
result = df.safe_divide(0)

expected = DataFrame({'A': [np.inf, np.inf, np.inf], 'B': [np.inf, np.inf, np.inf]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_with_series(self):
"""Test safe division with Series."""
df = DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
other = Series([2, 1, 0], index=['A', 'B', 'A'])

with pytest.warns(RuntimeWarning, match="Division by zero encountered"):
result = df.safe_divide(other, axis=0)

# The result should have inf where division by zero occurred
assert np.isinf(result.loc[0, 'A']).all() or np.isinf(result.loc[2, 'A']).all()

def test_safe_divide_with_nan_values(self):
"""Test safe division with NaN values."""
df = DataFrame({'A': [1, np.nan, 3], 'B': [4, 5, 6]})
other = DataFrame({'A': [2, 2, 0], 'B': [2, 2, 2]})

with pytest.warns(RuntimeWarning, match="Division by zero encountered"):
result = df.safe_divide(other)

expected = DataFrame({'A': [0.5, np.nan, np.inf], 'B': [2.0, 2.5, 3.0]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_invalid_zero_division(self):
"""Test safe division with invalid zero_division parameter."""
df = DataFrame({'A': [1, 2, 3]})

with pytest.raises(ValueError, match="zero_division must be one of"):
df.safe_divide(2, zero_division='invalid')

def test_safe_divide_preserves_index_and_columns(self):
"""Test that safe_divide preserves index and column names."""
df = DataFrame({'A': [1, 2], 'B': [3, 4]}, index=['x', 'y'])
other = DataFrame({'A': [2, 1], 'B': [2, 2]}, index=['x', 'y'])

result = df.safe_divide(other)

tm.assert_index_equal(result.index, df.index)
tm.assert_index_equal(result.columns, df.columns)

def test_safe_divide_with_fill_value(self):
"""Test safe division with fill_value parameter."""
df = DataFrame({'A': [1, np.nan, 3], 'B': [4, 5, 6]})
other = DataFrame({'A': [2, 2, 2], 'B': [2, 2, 2]})

result = df.safe_divide(other, fill_value=1)
expected = DataFrame({'A': [0.5, 0.5, 1.5], 'B': [2.0, 2.5, 3.0]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_axis_parameter(self):
"""Test safe division with different axis parameter."""
df = DataFrame({'A': [1, 2], 'B': [3, 4]})
other = Series([2, 1], index=['A', 'B'])

result = df.safe_divide(other, axis=1)
expected = DataFrame({'A': [0.5, 1.0], 'B': [3.0, 4.0]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_empty_dataframe(self):
"""Test safe division with empty DataFrame."""
df = DataFrame()
other = DataFrame()

result = df.safe_divide(other)
tm.assert_frame_equal(result, df)

def test_safe_divide_single_element(self):
"""Test safe division with single element DataFrame."""
df = DataFrame({'A': [1]})
other = DataFrame({'A': [2]})

result = df.safe_divide(other)
expected = DataFrame({'A': [0.5]})
tm.assert_frame_equal(result, expected)

def test_safe_divide_mixed_dtypes(self):
"""Test safe division with mixed data types."""
df = DataFrame({'A': [1, 2], 'B': [3.0, 4.0]})
other = DataFrame({'A': [2, 1], 'B': [2.0, 2.0]})

result = df.safe_divide(other)
expected = DataFrame({'A': [0.5, 2.0], 'B': [1.5, 2.0]})
tm.assert_frame_equal(result, expected)
Loading
Loading