Descriptors & Properties¶
Overview¶
Descriptors control how attributes are accessed:
- Properties:
@propertyfor computed attributes - Descriptors: Low-level attribute access control
- Custom getters/setters: Logic when accessing attributes
- Validation: Enforce constraints on attribute values
- Implications for ML: PyTorch parameters, TensorFlow variables, JAX pytrees
Properties with @property¶
Basic Properties¶
class Circle:
"""Circle with computed properties."""
def __init__(self, radius):
self._radius = radius # Private attribute
@property
def radius(self):
"""Get radius."""
return self._radius
@radius.setter
def radius(self, value):
"""Set radius with validation."""
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def diameter(self):
"""Computed property."""
return 2 * self._radius
@property
def area(self):
"""Computed property."""
import math
return math.pi * self._radius ** 2
# Use it
circle = Circle(5)
print(circle.radius) # 5 (uses getter)
print(circle.diameter) # 10 (computed)
print(circle.area) # 78.54... (computed)
circle.radius = 10 # Uses setter
print(circle.diameter) # 20
# circle.radius = -5 # ValueError!
Computed Properties in ML¶
import numpy as np
class NeuralNetworkLayer:
"""Layer with parameter properties."""
def __init__(self, input_size, output_size):
self._weights = np.random.randn(input_size, output_size)
self._bias = np.zeros(output_size)
@property
def weights(self):
"""Get weights."""
return self._weights
@weights.setter
def weights(self, value):
"""Set weights with validation."""
if value.shape != self._weights.shape:
raise ValueError("Shape mismatch")
self._weights = value
@property
def num_parameters(self):
"""Computed property: total parameters."""
return self._weights.size + self._bias.size
@property
def weight_norm(self):
"""Computed property: L2 norm of weights."""
return np.linalg.norm(self._weights)
# Use it
layer = NeuralNetworkLayer(10, 5)
print(f"Parameters: {layer.num_parameters}") # 55
print(f"Weight norm: {layer.weight_norm}") # Some value
Descriptors Protocol¶
Understanding get, set, delete¶
class Descriptor:
"""Base descriptor class."""
def __get__(self, obj, objtype=None):
"""Called when attribute is accessed."""
print(f"Getting {self}")
return "descriptor value"
def __set__(self, obj, value):
"""Called when attribute is assigned."""
print(f"Setting to {value}")
def __delete__(self, obj):
"""Called when attribute is deleted."""
print(f"Deleting")
class Example:
descriptor = Descriptor()
regular_attribute = "normal"
# Using it
obj = Example()
print(obj.descriptor) # Calls __get__
obj.descriptor = 10 # Calls __set__
del obj.descriptor # Calls __delete__
Type-Checking Descriptor¶
class TypedProperty:
"""Descriptor that enforces type."""
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} must be {self.expected_type.__name__}")
setattr(obj, self.private_name, value)
def __delete__(self, obj):
delattr(obj, self.private_name)
class Person:
name = TypedProperty("name", str)
age = TypedProperty("age", int)
def __init__(self, name, age):
self.name = name
self.age = age
# Use it
person = Person("Alice", 30)
print(person.name, person.age) # Alice 30
person.age = 31 # OK
# person.age = "thirty" # TypeError!
-
Lazy Properties¶
Computing on Demand¶
class LazyProperty:
"""Property computed only when first accessed."""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Compute and cache
value = self.func(obj)
# Store directly on instance (bypass descriptor next time)
setattr(obj, self.name, value)
return value
class DataProcessor:
def __init__(self, data_path):
self.data_path = data_path
@LazyProperty
def data(self):
"""Load data only when accessed."""
print(f"Loading data from {self.data_path}...")
import time
time.sleep(1) # Simulate slow loading
return [1, 2, 3, 4, 5]
# Use it
processor = DataProcessor("path/to/data.csv")
print("Created processor")
# First access
print(processor.data) # "Loading data..." then returns
# Second access
print(processor.data) # Just returns cached value
Practical ML Examples¶
PyTorch Parameter-like Behavior¶
class Parameter:
"""Mimics PyTorch parameter behavior."""
def __init__(self, data, requires_grad=True):
self._data = data
self.requires_grad = requires_grad
self.grad = None
@property
def data(self):
return self._data
@data.setter
def data(self, value):
self._data = value
@property
def shape(self):
return self._data.shape
@property
def dtype(self):
return self._data.dtype
class SimpleModel:
def __init__(self):
self.weight = Parameter([[1.0, 2.0], [3.0, 4.0]])
self.bias = Parameter([0.1, 0.2])
model = SimpleModel()
print(model.weight.shape) # (2, 2)
print(model.weight.requires_grad) # True
TensorFlow Variable-like Behavior¶
class Variable:
"""Mimics TensorFlow variable."""
def __init__(self, initial_value, trainable=True):
self._value = initial_value
self.trainable = trainable
@property
def value(self):
return self._value
@value.setter
def value(self, new_value):
self._value = new_value
@property
def dtype(self):
return self._value.dtype
def assign(self, value):
"""Assign new value."""
self._value = value
return self
var = Variable([1.0, 2.0, 3.0])
print(var.value) # [1, 2, 3]
var.assign([4.0, 5.0, 6.0])
print(var.value) # [4, 5, 6]
-
Related Topics¶
- [01 Classes & Inheritance](/05-py3/02-object-oriented-patterns/(01-classes-inheritance/) - Class fundamentals
- 02 Decorators - Property decorator implementation
- 03 Magic Methods - getattr, setattr
- [05 Custom Bytecode & Metaprogramming](/05-py3/09-bytecode-and-execution/(05-custom-bytecode-metaprogramming/) - Advanced descriptor patterns