Chapter 2: ufunc Create Function
1. Why would you ever need to create your own ufunc?
NumPy already has hundreds of ufuncs (sin, exp, maximum, logical_and, etc.), so why create more?
Real reasons you will actually need this:
- You have a custom mathematical function that you want to apply element-wise to arrays
- You want that function to be fast (vectorized), support broadcasting, and work nicely with where=, out=, etc.
- You want to use it in ufunc-style expressions like a + myfunc(b) * c
- You’re writing reusable numerical code (library, module, package) and want it to feel “native NumPy”
2. The four main ways to create element-wise functions (from worst to best)
| Method | Speed | Broadcasting | ufunc features (out, where, reduce…) | When to use |
|---|---|---|---|---|
| Python for-loop | very slow | no | no | never (for learning only) |
| np.vectorize | slow | yes | partial | quick prototypes, non-numeric functions |
| np.frompyfunc | medium | yes | full ufunc behavior | when you really need ufunc methods |
| Write in C / Cython / Numba | very fast | yes | full ufunc behavior | performance-critical code |
Today we’ll focus on the two practical NumPy-native ways:
- np.vectorize (easiest, but slowest)
- np.frompyfunc (true ufunc behavior)
3. Method 1 – np.vectorize (the beginner-friendly way)
vectorize takes a normal Python function and turns it into something that looks like it works element-wise.
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
def sigmoid_scalar(x): """Classic sigmoid function - scalar version""" return 1 / (1 + np.exp(-x)) # Turn it into vectorized version sigmoid = np.vectorize(sigmoid_scalar) # Now it works on arrays x = np.linspace(-8, 8, 200) y = sigmoid(x) plt.plot(x, y, lw=2.5, color='teal') plt.title("Sigmoid function via np.vectorize", fontsize=14) plt.xlabel("x") plt.ylabel("sigmoid(x)") plt.grid(True, alpha=0.3) plt.show() |
Important things you should notice
|
0 1 2 3 4 5 6 7 8 9 10 11 |
a = np.array([-2, -1, 0, 1, 2]) print(sigmoid(a)) # → array([0.11920292, 0.26894142, 0.5 , 0.73105858, 0.88079708]) # Broadcasting also works print(sigmoid(a + 3)) |
But… it is still slow
|
0 1 2 3 4 5 6 7 8 |
%timeit sigmoid(x) # many elements → slow %timeit 1 / (1 + np.exp(-x)) # native → 10–50× faster |
When to use vectorize
- Quick prototypes
- Functions that cannot be easily written with NumPy operations
- Non-numeric functions (strings, objects, conditionals that are hard to vectorize)
- You don’t care about maximum speed
4. Method 2 – np.frompyfunc (true ufunc behavior)
frompyfunc creates a real ufunc — it supports reduce, accumulate, outer, at, reduceat, where, out, etc.
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
def softsign_scalar(x): """softsign activation: x / (1 + |x|)""" return x / (1 + abs(x)) # Create real ufunc softsign = np.frompyfunc(softsign_scalar, nin=1, nout=1) x = np.linspace(-8, 8, 300) y = softsign(x) plt.plot(x, y, lw=2.5, color='purple') plt.title("softsign via np.frompyfunc", fontsize=14) plt.xlabel("x") plt.ylabel("softsign(x)") plt.axhline(0, color='gray', lw=0.8) plt.axvline(0, color='gray', lw=0.8) plt.show() |
ufunc features you now get for free
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# Broadcasting print(softsign(np.array([[-2, -1], [0, 1]]))) # Reduce (like sum, but with softsign) print(softsign.reduce([1, 2, 3, 4])) # softsign(softsign(softsign(softsign(1,2),3),4)) # Accumulate print(softsign.accumulate([1, 2, 3, 4])) # Outer product style print(softsign.outer([-2, -1, 0, 1], [0.5, 1.5])) |
5. Realistic examples – when you actually create custom ufuncs
Example 1 – Huber loss (smooth L1 loss)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
def huber_scalar(x, delta=1.0): """Huber loss – quadratic for small errors, linear for large""" ax = np.abs(x) return np.where(ax <= delta, 0.5 * x * x, delta * (ax - 0.5 * delta)) huber = np.frompyfunc(huber_scalar, nin=1, nout=1) errors = np.linspace(-5, 5, 500) loss = huber(errors, 1.5) plt.plot(errors, loss, lw=2.8, color='teal') plt.title("Huber loss via custom ufunc", fontsize=14) plt.xlabel("error") plt.ylabel("loss") plt.show() |
Example 2 – Swish activation (modern deep learning)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def swish_scalar(x): return x / (1 + np.exp(-x)) swish = np.frompyfunc(swish_scalar, nin=1, nout=1) x = np.linspace(-6, 6, 400) plt.plot(x, swish(x), lw=2.6, label="Swish") plt.plot(x, 1/(1+np.exp(-x)), lw=2.0, ls="--", label="Sigmoid") plt.title("Swish vs Sigmoid", fontsize=14) plt.legend() plt.show() |
6. Summary – Which method should you choose?
| Situation | Recommended tool |
|---|---|
| Quick test / prototype / non-numeric func | np.vectorize |
| You want real ufunc features (reduce, accumulate, outer…) | np.frompyfunc |
| You need maximum performance | Numba @vectorize, Cython, or hand-written C extension |
| You can write it with existing NumPy ops | just write it normally — no need for custom ufunc |
Final teacher advice (very important)
Golden rule #1 Try very hard to avoid creating a custom ufunc if you can express the operation using existing ufuncs + broadcasting + boolean indexing + np.where.
Golden rule #2 If you do need a custom function → start with np.vectorize for quick testing, then switch to np.frompyfunc if you need real ufunc behavior.
Golden rule #3 If speed matters a lot → learn Numba @vectorize or @guvectorize — it is usually much faster than frompyfunc.
Would you like to continue with any of these topics?
- Writing ufuncs with Numba (much faster than frompyfunc)
- Using frompyfunc with multiple inputs / outputs
- Creating ufuncs that work with dtype=object (strings, dates…)
- Realistic mini-project: custom activation functions + compare speed
- Common performance traps when using custom ufuncs
Just tell me what you want to focus on next! 😊
