Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Prerequisites: ch051 (What is a Function?), ch026 (Real Numbers), ch038 (Floating-Point)

You will learn:

  • Precise definitions of domain, codomain, and range (image)

  • Natural domain vs restricted domain

  • How domain restrictions arise in computation (division, square roots, logs)

  • How to determine range analytically and computationally

Environment: Python 3.x, numpy, matplotlib


1. Concept

The domain of a function f : A → B is the set A — all valid inputs. The codomain is the set B — all possible outputs by declaration. The range (or image) is {f(x) : x ∈ A} — all outputs that are actually produced.

The range is always a subset of the codomain, but they need not be equal.

Natural domain: the largest set on which a formula is defined without causing mathematical errors. For f(x) = 1/x, the natural domain is all real numbers except 0. For f(x) = √x, it is x ≥ 0.

Restricted domain: a subset of the natural domain, chosen deliberately. We might restrict f(x) = x² to x ≥ 0 to make it invertible.

Common misconception: Codomain and range are the same thing. They are not. f(x) = x² declared as f : ℝ → ℝ has codomain ℝ but range [0, ∞). The range is determined by the formula; the codomain is declared by the programmer or mathematician.

Why it matters in programming: Every time you call np.log(x) with x ≤ 0, or 1/x with x = 0, you have violated the domain. NumPy returns nan or inf instead of raising an error — a silent failure. Knowing the domain of every function you use prevents this.


2. Intuition & Mental Models

Physical analogy: A vending machine (from ch051) has a domain: the set of valid codes. If you enter an invalid code, nothing happens (or an error message appears). The domain is the set of codes that trigger a response. The range is the set of snacks that are actually available — a subset of everything in the world.

Computational analogy: In programming, the domain of a function is its valid input contract. The range is its valid output contract. When you write type annotations like def f(x: float) -> float, you are declaring the codomain (float), not the range.

Think of a function as a machine with a red zone (undefined inputs) and a green zone (valid inputs). The domain is the green zone. For log(x), the red zone is x ≤ 0. For sqrt(x) over the reals, the red zone is x < 0.


3. Visualization

# --- Visualization: Domain restrictions for common functions ---
import numpy as np
import matplotlib.pyplot as plt
plt.style.use('seaborn-v0_8-whitegrid')

fig, axes = plt.subplots(2, 3, figsize=(16, 9))

functions = [
    # (name, domain_range, fn, domain_label, range_label)
    ('f(x) = 1/x',  np.concatenate([np.linspace(-3, -0.01, 200), np.linspace(0.01, 3, 200)]),
     lambda x: 1/x, 'x ≠ 0', 'y ≠ 0'),
    ('f(x) = √x',   np.linspace(0, 9, 400),
     np.sqrt, 'x ≥ 0', 'y ≥ 0'),
    ('f(x) = log(x)', np.linspace(0.01, 10, 400),
     np.log, 'x > 0', 'all reals'),
    ('f(x) = x²',   np.linspace(-3, 3, 400),
     lambda x: x**2, 'all reals', 'y ≥ 0'),
    ('f(x) = sin(x)', np.linspace(-2*np.pi, 2*np.pi, 400),
     np.sin, 'all reals', '[-1, 1]'),
    ('f(x) = arcsin(x)', np.linspace(-1, 1, 400),
     np.arcsin, '[-1, 1]', '[-π/2, π/2]'),
]

for ax, (name, x_vals, fn, dom, rng) in zip(axes.flat, functions):
    y_vals = fn(x_vals)
    ax.plot(x_vals, y_vals, color='steelblue', linewidth=2)
    ax.set_title(f'{name}\nDomain: {dom}  |  Range: {rng}', fontsize=9)
    ax.set_xlabel('x')
    ax.set_ylabel('f(x)')
    # Shade the valid domain
    ax.axhline(0, color='black', linewidth=0.5)
    ax.axvline(0, color='black', linewidth=0.5)

plt.suptitle('Domain and Range for Common Functions', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.show()
<Figure size 1600x900 with 6 Axes>

4. Mathematical Formulation

For a function f : A → B:

  • Domain: dom(f) = A

  • Codomain: cod(f) = B

  • Range (image): im(f) = {f(x) : x ∈ A} ⊆ B

Surjective (onto): im(f) = B — every element of B is hit Injective (one-to-one): f(x₁) = f(x₂) implies x₁ = x₂ — no two inputs share an output Bijective: both injective and surjective — perfect pairing between A and B

These properties determine whether a function has an inverse (ch055).

# --- Mathematical Formulation: Determining range numerically ---
import numpy as np

def numerical_range(f, domain_start, domain_end, n_samples=10000):
    """
    Estimate the range of function f over a domain interval.
    Uses dense sampling — gives approximate min and max of the range.
    
    Args:
        f: callable, function from reals to reals
        domain_start, domain_end: float, domain interval
        n_samples: int, number of sample points
    Returns:
        (min_val, max_val): approximate range bounds
    """
    x = np.linspace(domain_start, domain_end, n_samples)
    y = f(x)
    # Filter out nan/inf (undefined points)
    y_valid = y[np.isfinite(y)]
    if len(y_valid) == 0:
        return None, None
    return y_valid.min(), y_valid.max()

# Test on known functions
test_cases = [
    ('x²  on [-3, 3]',  lambda x: x**2,   -3, 3),
    ('sin(x) on [-2π, 2π]', np.sin,       -2*np.pi, 2*np.pi),
    ('log(x) on [0.01, 10]', np.log,       0.01, 10),
    ('1/x on [0.1, 5]', lambda x: 1/x,    0.1, 5),
]

for name, f, a, b in test_cases:
    lo, hi = numerical_range(f, a, b)
    print(f"Range of {name}: [{lo:.4f}, {hi:.4f}]")
Range of x²  on [-3, 3]: [0.0000, 9.0000]
Range of sin(x) on [-2π, 2π]: [-1.0000, 1.0000]
Range of log(x) on [0.01, 10]: [-4.6052, 2.3026]
Range of 1/x on [0.1, 5]: [0.2000, 10.0000]

5. Python Implementation

# --- Implementation: Domain-aware function wrapper ---
import numpy as np

class DomainAwareFunction:
    """
    Wraps a mathematical function with explicit domain checking.
    Raises ValueError for inputs outside the domain.
    Provides a safe vectorized apply method.
    """
    
    def __init__(self, f, domain_predicate, name='f', domain_description='unknown'):
        """
        Args:
            f: callable, the underlying function
            domain_predicate: callable, returns True if x is in domain
            name: str, function name for error messages
            domain_description: str, human-readable domain description
        """
        self.f = f
        self.domain_predicate = domain_predicate
        self.name = name
        self.domain_description = domain_description
    
    def __call__(self, x):
        """Apply function, raising ValueError if x is outside domain."""
        x = np.asarray(x, dtype=float)
        valid = self.domain_predicate(x)
        if not np.all(valid):
            bad = x[~valid] if x.ndim > 0 else x
            raise ValueError(
                f"{self.name}: input {bad} outside domain ({self.domain_description})"
            )
        return self.f(x)
    
    def safe_apply(self, x, fill_value=np.nan):
        """Apply function, returning fill_value for out-of-domain inputs."""
        x = np.asarray(x, dtype=float)
        result = np.full_like(x, fill_value)
        valid = self.domain_predicate(x)
        result[valid] = self.f(x[valid])
        return result

# Create domain-aware versions of common functions
sqrt_safe = DomainAwareFunction(
    np.sqrt, lambda x: x >= 0, name='sqrt', domain_description='x >= 0'
)
log_safe = DomainAwareFunction(
    np.log, lambda x: x > 0, name='log', domain_description='x > 0'
)

# Demonstrate safe_apply with mixed valid/invalid inputs
test_inputs = np.array([-4, -1, 0, 1, 4, 9, 16])

print("Input:", test_inputs)
print("sqrt_safe:", sqrt_safe.safe_apply(test_inputs))   # nan for negatives
print("log_safe: ", log_safe.safe_apply(test_inputs))    # nan for non-positives
Input: [-4 -1  0  1  4  9 16]
sqrt_safe: [nan nan  0.  1.  2.  3.  4.]
log_safe:  [       nan        nan        nan 0.         1.38629436 2.19722458
 2.77258872]

6. Experiments

# --- Experiment 1: What happens when you violate a domain? ---
# Hypothesis: NumPy silently handles domain violations with nan/inf rather than erroring.
# Try: change which function and which values you test.
import numpy as np

print("Domain violations and NumPy's responses:")
print(f"  log(0)    = {np.log(0)}")
print(f"  log(-1)   = {np.log(-1)}")
print(f"  sqrt(-1)  = {np.sqrt(-1)}")
print(f"  1/0       = {1.0/0.0 if False else 'ZeroDivisionError — use np.float64'}")  
print(f"  np.float64(1)/np.float64(0) = {np.float64(1)/np.float64(0)}")
print(f"  arcsin(2) = {np.arcsin(2)}")

# The silent nan/inf is DANGEROUS in ML — it propagates through all downstream ops
x = np.array([1.0, 0.0, -1.0, 4.0])
result = np.log(x)  # produces nan for non-positive values
print("\nlog([1, 0, -1, 4]) =", result)
print("sum =>", np.sum(result))  # NaN poisons everything!
print("\nLesson: always validate domain BEFORE applying the function.")
Domain violations and NumPy's responses:
  log(0)    = -inf
  log(-1)   = nan
  sqrt(-1)  = nan
  1/0       = ZeroDivisionError — use np.float64
  np.float64(1)/np.float64(0) = inf
  arcsin(2) = nan

log([1, 0, -1, 4]) = [0.               -inf        nan 1.38629436]
sum => nan

Lesson: always validate domain BEFORE applying the function.
C:\Users\user\AppData\Local\Temp\ipykernel_9356\438985284.py:7: RuntimeWarning: divide by zero encountered in log
  print(f"  log(0)    = {np.log(0)}")
C:\Users\user\AppData\Local\Temp\ipykernel_9356\438985284.py:8: RuntimeWarning: invalid value encountered in log
  print(f"  log(-1)   = {np.log(-1)}")
C:\Users\user\AppData\Local\Temp\ipykernel_9356\438985284.py:9: RuntimeWarning: invalid value encountered in sqrt
  print(f"  sqrt(-1)  = {np.sqrt(-1)}")
C:\Users\user\AppData\Local\Temp\ipykernel_9356\438985284.py:11: RuntimeWarning: divide by zero encountered in scalar divide
  print(f"  np.float64(1)/np.float64(0) = {np.float64(1)/np.float64(0)}")
C:\Users\user\AppData\Local\Temp\ipykernel_9356\438985284.py:12: RuntimeWarning: invalid value encountered in arcsin
  print(f"  arcsin(2) = {np.arcsin(2)}")
C:\Users\user\AppData\Local\Temp\ipykernel_9356\438985284.py:16: RuntimeWarning: divide by zero encountered in log
  result = np.log(x)  # produces nan for non-positive values
C:\Users\user\AppData\Local\Temp\ipykernel_9356\438985284.py:16: RuntimeWarning: invalid value encountered in log
  result = np.log(x)  # produces nan for non-positive values
# --- Experiment 2: Injective, surjective, bijective ---
# Hypothesis: We can test these properties numerically for functions on finite sets.
# Try: change the function and the domain size.

import numpy as np

DOMAIN = list(range(1, 8))  # {1, 2, 3, 4, 5, 6, 7}  # <-- try changing this

def test_properties(f, domain, codomain=None):
    """Test injective and surjective properties numerically."""
    outputs = [f(x) for x in domain]
    
    # Injective: all outputs are distinct
    injective = len(outputs) == len(set(outputs))
    
    # Surjective: only testable if codomain is known
    surjective = None
    if codomain is not None:
        surjective = set(outputs) == set(codomain)
    
    return injective, surjective, outputs

# Test several functions
tests = [
    ('x → x² mod 7',  lambda x: (x**2) % 7, None),
    ('x → x mod 7',   lambda x: x % 7,      list(range(7))),
    ('x → 2x mod 7',  lambda x: (2*x) % 7,  list(range(7))),
    ('x → 3x mod 7',  lambda x: (3*x) % 7,  list(range(7))),
]

for name, f, cod in tests:
    inj, surj, out = test_properties(f, DOMAIN, cod)
    print(f"{name}:")
    print(f"  Outputs: {out}")
    print(f"  Injective: {inj}  |  Surjective: {surj}")
x → x² mod 7:
  Outputs: [1, 4, 2, 2, 4, 1, 0]
  Injective: False  |  Surjective: None
x → x mod 7:
  Outputs: [1, 2, 3, 4, 5, 6, 0]
  Injective: True  |  Surjective: True
x → 2x mod 7:
  Outputs: [2, 4, 6, 1, 3, 5, 0]
  Injective: True  |  Surjective: True
x → 3x mod 7:
  Outputs: [3, 6, 2, 5, 1, 4, 0]
  Injective: True  |  Surjective: True

7. Exercises

Easy 1. State the natural domain of each: (a) f(x) = (x-3)/(x²-9) (b) f(x) = log(log(x)) (c) f(x) = √(4-x²). (Expected: (a) x≠3 and x≠-3, (b) x>1, (c) -2≤x≤2)

Easy 2. For f(x) = x² with domain restricted to [0, 5], what is the range? Verify computationally by generating 10000 uniform random samples in [0,5] and finding the min/max of f(x). (Expected: [0, 25])

Medium 1. Write a function find_domain_intervals(f, x_min, x_max, n=10000) that samples f over [x_min, x_max] and returns a list of contiguous intervals where f is defined (i.e., produces finite values). Test on f(x) = log(x² - 4). (Hint: find where output is finite, then group contiguous indices)

Medium 2. The function f(x) = x² is not injective over ℝ, but becomes injective if restricted to [0, ∞). Write a function is_injective_on(f, domain_array, tolerance=1e-9) and verify this claim. (Hint: round values to handle floating point)

Hard. For f(x) = sin(x) on [-π/2, π/2], demonstrate numerically that f is both injective and surjective onto [-1, 1] (i.e., bijective). Then show that on [-π, π], f is surjective but not injective. Prove the non-injectivity by finding two distinct x values with the same f(x) value. (Challenge: what is the minimum domain extension needed to break injectivity?)


8. Mini Project

# --- Mini Project: Domain Boundary Detector ---
# Problem: Many ML preprocessing functions have hidden domain restrictions.
#          Build a tool that automatically detects where a function fails
#          and visualizes the valid and invalid regions.
# Dataset: Dense sample grid over a range containing edge cases.
# Task: For each test function, detect valid/invalid regions and plot them.

import numpy as np
import matplotlib.pyplot as plt
plt.style.use('seaborn-v0_8-whitegrid')

def detect_domain(f, x_min, x_max, n=5000):
    """
    Detect valid (finite) and invalid (nan/inf) input regions.
    Returns x_valid, y_valid, x_invalid.
    """
    x = np.linspace(x_min, x_max, n)
    with np.errstate(divide='ignore', invalid='ignore'):
        y = f(x)
    finite_mask = np.isfinite(y)
    return x[finite_mask], y[finite_mask], x[~finite_mask]

# Test on functions with interesting domain structures
funcs = [
    ('log(x² - 1)',  lambda x: np.log(x**2 - 1),  -3, 3),
    ('sqrt(sin(x))', lambda x: np.sqrt(np.sin(x)), -2*np.pi, 2*np.pi),
    ('1/(x²-3x+2)', lambda x: 1/(x**2 - 3*x + 2), -1, 4),
    ('arcsin(x/2)', lambda x: np.arcsin(x/2),      -3, 3),
]

fig, axes = plt.subplots(2, 2, figsize=(14, 8))

for ax, (name, f, a, b) in zip(axes.flat, funcs):
    xv, yv, xi = detect_domain(f, a, b)
    ax.plot(xv, yv, color='steelblue', linewidth=1.5, label='Defined')
    ax.scatter(xi, np.zeros_like(xi), color='crimson', s=2, alpha=0.3, label='Undefined')
    ax.axhline(0, color='black', linewidth=0.5)
    ax.set_title(f'f(x) = {name}')
    ax.set_xlabel('x')
    ax.set_ylabel('f(x)')
    ax.legend(fontsize=8)

plt.suptitle('Domain Boundary Detection', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.show()

9. Chapter Summary & Connections

What we covered:

  • Domain = valid inputs; Codomain = declared output set; Range = actual outputs produced

  • Natural domain is determined by mathematical constraints (no division by zero, no log of negatives)

  • NumPy silently produces nan/inf for domain violations — always validate inputs

  • Injective (one-to-one) and surjective (onto) describe the input-output coverage properties

Backward connection: This makes precise what ch051 left vague — a function needs not just a rule but a set of valid inputs.

Forward connections:

  • In ch055 (Inverse Functions), we will see that injectivity is exactly the condition needed for an inverse to exist

  • Domain restrictions reappear in ch073 (Error and Residuals) — a model’s domain must cover all data points

  • In ch241 (Probability), sample spaces are the domain of probability functions — same concept, probabilistic setting