{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# More Advanced Python\n", "\n", "There are several advanced Python notions that you need to be familiar with. \n", "\n", "## Decorators\n", "\n", "\n", "### What is a Decorator?\n", "\n", "A **decorator** is a function that takes another function as input, adds some functionality to it, and returns it. You can apply decorators using the `@decorator_name` syntax.\n", "\n", "\n", "\n", "### Basic Example of a Decorator\n" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Something is happening before the function is called.\n", "Hello!\n", "Something is happening after the function is called.\n" ] } ], "source": [ "# Define a simple decorator\n", "def my_decorator(func):\n", " def wrapper():\n", " print(\"Something is happening before the function is called.\")\n", " func()\n", " print(\"Something is happening after the function is called.\")\n", " return wrapper\n", "\n", "# Define a function and decorate it\n", "@my_decorator\n", "def say_hello():\n", " print(\"Hello!\")\n", "\n", "# Call the decorated function\n", "say_hello()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`@my_decorator` is syntactic sugar for:\n", " ```python\n", " say_hello = my_decorator(say_hello)\n", " ```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Decorator with Arguments\n" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello, Boris!\n", "Hello, Boris!\n", "Hello, Boris!\n" ] } ], "source": [ "def repeat_decorator(times):\n", " def decorator(func):\n", " def wrapper(*args, **kwargs):\n", " for _ in range(times):\n", " func(*args, **kwargs)\n", " return wrapper\n", " return decorator\n", "\n", "@repeat_decorator(times=3)\n", "def greet(name):\n", " print(f\"Hello, {name}!\")\n", "\n", "greet(\"Boris\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here:\n", "\n", "- The `repeat_decorator` takes an argument (`times`) and returns the actual decorator.\n", "\n", "- The decorator wraps the `greet` function to execute it multiple times.\n", "\n", "\n", "### Using `functools.wraps` to Preserve Metadata\n", "\n", "When you wrap a function using a decorator, the original function's metadata (like its name and docstring) can be lost. \n", "\n", "Use `functools.wraps` to preserve it.\n" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Calling decorated function...\n", "Example function is running!\n", "example_function\n", "This is an example function.\n" ] } ], "source": [ "import functools\n", "\n", "def my_decorator(func):\n", " @functools.wraps(func)\n", " def wrapper(*args, **kwargs):\n", " \"\"\"Wrapper function.\"\"\"\n", " print(\"Calling decorated function...\")\n", " return func(*args, **kwargs)\n", " return wrapper\n", "\n", "@my_decorator\n", "def example_function():\n", " \"\"\"This is an example function.\"\"\"\n", " print(\"Example function is running!\")\n", "\n", "example_function()\n", "print(example_function.__name__) # Output: example_function\n", "print(example_function.__doc__) # Output: This is an example function." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "In this example, we see that the metadata of the decorated function is preserved, namely the name (`__name__`) and the docstring (`__doc__`)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Class-Based Decorators\n", "\n", "Decorators can also be implemented as classes.\n" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Before the function call.\n", "Hello!\n", "After the function call.\n" ] } ], "source": [ "class MyDecorator:\n", " def __init__(self, func):\n", " self.func = func\n", "\n", " def __call__(self, *args, **kwargs):\n", " print(\"Before the function call.\")\n", " result = self.func(*args, **kwargs)\n", " print(\"After the function call.\")\n", " return result\n", " \n", "@MyDecorator\n", "def say_hello():\n", " print(\"Hello!\")\n", "\n", "say_hello()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n", "\n", "Here, the `__call__` method makes the class instance callable, so it acts like a decorator. In this case, you don't need to define a wrapper function inside the decorator.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Chaining Multiple Decorators\n", "\n", "You can apply multiple decorators to a function.\n" ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "HELLO, BORIS!\n" ] } ], "source": [ "def uppercase(func):\n", " def wrapper(*args, **kwargs):\n", " result = func(*args, **kwargs)\n", " return result.upper()\n", " return wrapper\n", "\n", "def exclaim(func):\n", " def wrapper(*args, **kwargs):\n", " result = func(*args, **kwargs)\n", " return result + \"!\"\n", " return wrapper\n", "\n", "@exclaim\n", "@uppercase\n", "def greet(name):\n", " return f\"hello, {name}\"\n", "\n", "print(greet(\"Boris\"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "- `@uppercase` is applied first, transforming the string to uppercase.\n", "- `@exclaim` is applied next, adding an exclamation mark." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n", "### Real-World Example: \n", "\n", "#### Timing a Function" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Finished!\n", "Execution time: 2.0050 seconds\n", "Finished!\n", "Execution time: 0.1015 seconds\n" ] } ], "source": [ "import time\n", "\n", "def timing_decorator(func):\n", " def wrapper(*args, **kwargs):\n", " start_time = time.time()\n", " result = func(*args, **kwargs)\n", " end_time = time.time()\n", " print(f\"Execution time: {end_time - start_time:.4f} seconds\")\n", " return result\n", " return wrapper\n", "\n", "@timing_decorator\n", "def slow_function():\n", " time.sleep(2)\n", " print(\"Finished!\")\n", "\n", "\n", "\n", "@timing_decorator\n", "def fast_function():\n", " time.sleep(0.1)\n", " print(\"Finished!\")\n", "\n", "slow_function()\n", "fast_function()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "In this example we don't need to re-implement the timing logic, thanks to the decorator.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Vectorizing a Function\n", "\n", "\n", "If you have a regular function and want to apply `np.vectorize`, you can explicitly wrap the function with `np.vectorize`." ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[0 0 0 1 4]\n" ] } ], "source": [ "import numpy as np\n", "\n", "# Define a regular function\n", "def my_function(x):\n", " return x ** 2 if x > 0 else 0\n", "\n", "# Vectorize it\n", "vectorized_function = np.vectorize(my_function)\n", "\n", "# Apply it to a NumPy array\n", "data = np.array([-2, -1, 0, 1, 2])\n", "result = vectorized_function(data)\n", "print(result) # Output: [0 0 0 1 4]" ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [], "source": [ "# my_function(data)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n", "Using `np.vectorize` as a decorator simplifies the process and automatically transforms the function for vectorized operations.\n" ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[0 0 0 1 4]\n" ] } ], "source": [ "import numpy as np\n", "\n", "# Use np.vectorize as a decorator\n", "@np.vectorize\n", "def my_function(x):\n", " return x ** 2 if x > 0 else 0\n", "\n", "# Apply it to a NumPy array\n", "data = np.array([-2, -1, 0, 1, 2])\n", "result = my_function(data)\n", "print(result) # Output: [0 0 0 1 4]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here, the `@np.vectorize` decorator is applied directly to `my_function`, so you don't need to explicitly wrap it later.\n", "\n", "\n", "To recap:\n", "\n", "1. **Without Decorator**:\n", "\n", " - You have to manually apply `np.vectorize` to the function.\n", "\n", " - More verbose.\n", "\n", "2. **With Decorator**:\n", "\n", " - Cleaner and more readable.\n", "\n", " - Automatically makes the function vectorized when defined.\n", "\n", "\n", "\n", "`np.vectorize` is useful for extending scalar functions to arrays when you don't want to manually iterate over elements. \n", "\n", "However, it doesn't provide true performance benefits like NumPy's `ufuncs` (universal functions), as it’s just a convenience for element-wise operations. If possible, prefer writing functions that natively work with arrays for better performance.\n", "\n" ] }, { "cell_type": "code", "execution_count": 19, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "array([1.41421356, 1. , 0. , 1. , 1.41421356])" ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "np.sqrt(np.abs(data))" ] }, { "cell_type": "code", "execution_count": 20, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "array([1.41421356, 1. , 0. , 1. , 1.41421356])" ] }, "execution_count": 20, "metadata": {}, "output_type": "execute_result" } ], "source": [ "np.abs(data)**0.5" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Numpy Universal Functions (ufuncs)\n", "\n", "A universal function (or ufunc for short) is a function that operates on ndarrays in an element-by-element fashion, supporting array broadcasting, type casting, and several other standard features. That is, a ufunc is a “vectorized” wrapper for a function that takes a fixed number of specific inputs and produces a fixed number of specific outputs (see [here](https://numpy.org/doc/2.1/user/basics.ufuncs.html#ufuncs-basics)).\n", "\n", "For example:" ] }, { "cell_type": "code", "execution_count": 21, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[5 7 9]\n" ] } ], "source": [ "import numpy as np\n", "\n", "# Element-wise addition\n", "arr1 = np.array([1, 2, 3])\n", "arr2 = np.array([4, 5, 6])\n", "\n", "result = arr1 + arr2 # Uses ufunc np.add\n", "print(result) # Output: [5 7 9]\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Square-root, and trigonometric functions in numpy are also ufuncs:" ] }, { "cell_type": "code", "execution_count": 22, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[1. 2. 3. 4.]\n", "[0.0000000e+00 1.0000000e+00 1.2246468e-16]\n" ] } ], "source": [ "arr = np.array([1, 4, 9, 16])\n", "\n", "# Square root\n", "sqrt_result = np.sqrt(arr)\n", "print(sqrt_result) # Output: [1. 2. 3. 4.]\n", "\n", "# Trigonometric functions\n", "angles = np.array([0, np.pi / 2, np.pi])\n", "sin_result = np.sin(angles)\n", "print(sin_result) # Output: [0. 1. 0.]\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can create a custom ufunc from a Python function as well. " ] }, { "cell_type": "code", "execution_count": 23, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[5 7 9]\n" ] } ], "source": [ "def custom_add(x, y):\n", " return x + y\n", "\n", "# Create ufunc\n", "custom_add_ufunc = np.frompyfunc(custom_add, 2, 1)\n", "\n", "result = custom_add_ufunc([1, 2, 3], [4, 5, 6])\n", "print(result) # Output: [5 7 9]\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here is an example of the huge performance benefits of ufuncs:" ] }, { "cell_type": "code", "execution_count": 24, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "ufunc time: 0.007639 seconds\n", "np.vectorize time: 0.117718 seconds\n" ] } ], "source": [ "import numpy as np\n", "import time\n", "\n", "# Data\n", "arr = np.arange(1e6)\n", "\n", "# Using ufunc\n", "start = time.time()\n", "result_ufunc = np.sqrt(arr)\n", "end = time.time()\n", "print(f\"ufunc time: {end - start:.6f} seconds\")\n", "\n", "# Using np.vectorize\n", "def scalar_sqrt(x):\n", " return x ** 0.5\n", "\n", "vectorized_sqrt = np.vectorize(scalar_sqrt)\n", "start = time.time()\n", "result_vectorized = vectorized_sqrt(arr)\n", "end = time.time()\n", "print(f\"np.vectorize time: {end - start:.6f} seconds\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "In this example, the ufunc version is 100 times faster than the vectorized version. The vectorized version actually iterates over the elements of the array, while the ufunc version is implemented in C under the hood and is highly optimized." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Logging\n", "\n", "Logging is used to track events in a program and helps debug or monitor it without relying on print statements. \n", "\n", "It’s better because it categorizes messages by **severity levels** (e.g., `INFO`, `WARNING`, `ERROR`) and can output logs to different destinations (e.g., files).\n", "\n", "\n", "### Basic Setup" ] }, { "cell_type": "code", "execution_count": 25, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "INFO: Computed square of 5: 25\n", "WARNING: Received a negative number. Returning zero.\n" ] }, { "data": { "text/plain": [ "0" ] }, "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import logging\n", "\n", "# Configure basic logging\n", "logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')\n", "\n", "# Example: A simple computation\n", "def compute_square(x):\n", " if x < 0:\n", " logging.warning(\"Received a negative number. Returning zero.\")\n", " return 0\n", " result = x ** 2\n", " logging.info(f\"Computed square of {x}: {result}\")\n", " return result\n", "\n", "# Call the function\n", "compute_square(5)\n", "compute_square(-3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "\n", "1. Log Levels Used:\n", "\n", " - `INFO`: Logs useful computation results.\n", "\n", " - `WARNING`: Logs when unexpected input is received.\n", "\n", "2. Why Use Logging?\n", "\n", " - It separates debugging messages from your main program output.\n", "\n", " - It's easy to expand (e.g., write to files, add timestamps).\n", "\n", "\n", "### Activating and Deactivating Logging\n", "\n", "Sometimes, you may want to turn logging **on** or **off** depending on the situation (e.g., during debugging or production).\n", "\n", "\n", "\n", "**How to Deactivate Logging?**\n", "\n", "To deactivate logging, set the logging level to `logging.CRITICAL`. Since this is the highest level, only critical errors will be logged, effectively \"silencing\" other logs.\n" ] }, { "cell_type": "code", "execution_count": 26, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "INFO: Computed square of 5: 25\n", "WARNING: Received a negative number. Returning zero.\n" ] }, { "data": { "text/plain": [ "0" ] }, "execution_count": 26, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import logging\n", "\n", "# Configure basic logging\n", "logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')\n", "\n", "# Example function with logging\n", "def compute_square(x):\n", " if x < 0:\n", " logging.warning(\"Received a negative number. Returning zero.\")\n", " return 0\n", " result = x ** 2\n", " logging.info(f\"Computed square of {x}: {result}\")\n", " return result\n", "\n", "# Activate logging (default level INFO)\n", "compute_square(5) # Logs: INFO: Computed square of 5: 25\n", "compute_square(-3) # Logs: WARNING: Received a negative number. Returning zero.\n", "\n" ] }, { "cell_type": "code", "execution_count": 27, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "0" ] }, "execution_count": 27, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Deactivate logging\n", "logging.getLogger().setLevel(logging.CRITICAL)\n", "\n", "compute_square(5) # No logs\n", "compute_square(-3) # No logs" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**How to Reactivate Logging?**" ] }, { "cell_type": "code", "execution_count": 28, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "INFO: Computed square of 5: 25\n", "WARNING: Received a negative number. Returning zero.\n" ] }, { "data": { "text/plain": [ "0" ] }, "execution_count": 28, "metadata": {}, "output_type": "execute_result" } ], "source": [ "logging.getLogger().setLevel(logging.INFO)\n", "\n", "compute_square(5) # Logs: INFO: Computed square of 5: 25\n", "compute_square(-3) # Logs: WARNING: Received a negative number. Returning zero." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This allows you to easily toggle logging without removing or modifying your logging statements." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Global and Local Variables\n", "\n", "Variables in python can have global or local scope. \n", "\n", "**Global variables** are accessible everywhere.\n", "\n", "**Local variables** are only accessible in the function they are defined in.\n", "\n", "Variables created in `for`, `while` and `if` statements are **global**, so available outside the loop (as are the iterators, e.g. `i` in `for i in range(10)`). " ] }, { "cell_type": "code", "execution_count": 31, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "100\n", "2\n", "101\n", "102\n" ] } ], "source": [ "for i in range(3):\n", " j=100\n", "print(j)\n", "print(i)\n", "i=0\n", "\n", "while i<2:\n", " j=101\n", " i=5\n", "print(j) \n", "\n", "if (True):\n", " j=102\n", "print(j)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "