diff --git a/lectures/python_fundamentals/functions.md b/lectures/python_fundamentals/functions.md index c0c460c0..9400b5f0 100644 --- a/lectures/python_fundamentals/functions.md +++ b/lectures/python_fundamentals/functions.md @@ -582,6 +582,8 @@ In that example, the `z` on the left hand side of `z = z` refers to the local variable name in the function whereas the `z` on the right hand side refers to the `z` in the outer scope. + + ### Aside: Methods As we learned earlier, all variables in Python have a type associated @@ -625,6 +627,74 @@ s.upper() s.title() ``` + +### Creating Custom Types + +Python allows for Object-Oriented Programming (OOP), allowing you to define your own custom types and merge together some sets of parameters with custom methods. This can help you streamline your code by making it more modular. + +We are used to defining variables like `x = dict("a": 1, "b": 2)` and then using notation like `x["a"]` to access the value of `1`. We can also define our own custom types and use them in similar ways. + +For example, a simple class that stores two variables would look like this: + +```{code-cell} python +class A: + def __init__(self, x, y): + self.x = x + self.y = y +``` + +Used both internal and external to classes, the `__init__` method is a special method that is called when an object is created. It is used to initialize the object's state. The `self` argument refers to the object itself. The `self` argument is always the first argument of any method in a class. The `self` argument is not passed in when the method is called, but Python will pass in the object itself when the method is called. + +A class, defined by the `class` keyword, is a blueprint for an object. It defines the attributes and methods that an object will have. An object is an instance of a class that has been created and assigned to a variable. It is created by calling the class name as if it were a function. When you call the class name, the object is created and the `__init__` method is called by default. + +```{code-cell} python +a = A(1, 2) +b = A(3, 4) +# Notice that these are different objects +a == b +``` + +You can see that `a` and `b` are both instances of the `A` class by using the `type` function. + +```{code-cell} python +type(a) +``` +Point at the debugger to see the `a.x` etc. fields +You can access the attributes of an object using the dot notation. For example, to access the `x` attribute of the `a` object, you would use `a.x`. + +```{code-cell} python +print(f"a.x = {a.x} and a.y = {a.y}") +``` + +In addition to attributes, objects can also have methods. Methods are functions that are defined inside of a class. They are accessed using the dot notation as well. For example, let's define a method that adds the `x` and `y` attributes of an object. + + +```{code-cell} python +class B: + def __init__(self, x, y): + self.x = x + self.y = y + + def add(self): + return self.x + self.y +``` + +We can now create an object of type `B` and call the `add` method, in the same way that we called methods on built-in types (like the `.upper()` method on a string.) + +```{code-cell} python +b = B(1, 2) +print(b.add()) +``` + +Using custom classes can often be a helpful way to organize your code and make it more modular, by grouping together related variables and functions. Understanding how to create and use custom classes is also a key part of understanding how Python works under the hood, and can be crucial to using some of the more advanced Python packages (like [PyTorch](https://pytorch.org/tutorials/beginner/basics/quickstart_tutorial.html).) + +````{admonition} Exercise +:name: dir2-4-5 + +See exercise 5 in the {ref}`exercise list `. +```` + + ## More on Scope (Optional) Keep in mind that with mathematical functions, the arguments are just dummy names @@ -789,3 +859,19 @@ These can *only* be set by name. ``` ({ref}`back to text `) + +### Exercise 5 + +Define a custom class called `CobbDouglas` that collects the parameters `z` and `alpha` as attributes, and has a method called `produce` that takes `K` and `L` as arguments and returns the output from the Cobb-Douglas production function. + +```{code-cell} python +# Your code here. +``` + +Now create an instance of the `CobbDouglas` class called `cobb_douglas1` with `z = 1` and `alpha = 0.33`. Use the `produce` method to compute the output when `K = 1` and `L = 0.5`. + +```{code-cell} python +# Your code here. +``` + +({ref}`back to text `) diff --git a/lectures/scientific/randomness.md b/lectures/scientific/randomness.md index 419375da..cc5172a9 100644 --- a/lectures/scientific/randomness.md +++ b/lectures/scientific/randomness.md @@ -272,6 +272,110 @@ element at a time. For more information see the [QuantEcon lecture on performance Python](https://python-programming.quantecon.org/numba.html) code. + +### Aside: Using Class to Hold Parameters + +We have been using objects and classes both internal to python (e.g. `list`) from external libraries (e.g. `numpy.array`). Sometimes it is convenient to create your own classes to organize parameter, data, and functions. + +In this section we will reimplement our function using new classes to hold parameters. + +First, we rewrite `simulate_loan_repayments` so that instead of a collection of individual parameters, it takes in an object (titles `params`). + +```{code-cell} python +def simulate_loan_repayments_2(N, params): + # Extract fields from params object + r = params.r + repayment_part = params.repayment_part + repayment_full = params.repayment_full + + random_numbers = np.random.rand(N) + + # start as 0 -- no repayment + repayment_sims = np.zeros(N) + + # adjust for full and partial repayment + partial = random_numbers <= 0.20 + repayment_sims[partial] = repayment_part + + full = ~partial & (random_numbers <= 0.95) + repayment_sims[full] = repayment_full + + repayment_sims = (1 / (1 + r)) * repayment_sims + + return repayment_sims +``` + +Any object which fulfills `params.r, params.replayment_part` and `params.repayment_full` will work, so we will create a few versions of this to explore features of custom classes in Python. + +The most important function in a class is the `__init__` function which determines how it is constructed and creates an object of that type. This function has the special argument `self` which refers to the new object being created, and with which you can easily add new fields. For example, + +```{code-cell} python +class LoanRepaymentParams: + # A special function 'constructor' + def __init__(self, r, repayment_full, repayment_part): + self.r = r + self.repayment_full = repayment_full + self.repayment_part = repayment_part + +# Create an instance of the class +params = LoanRepaymentParams(0.05, 50_000.0, 25_000) +print(params.r) +``` + +The inside of the `__init__` function simply takes the arguments and assigns them as new fields in the `self`. Calling the `LoanRepaymentParams(...)` implicitly calls the `__init__` function and returns the new object. + +We can then use the new object to call the function `simulate_loan_repayments_2` as before. + +```{code-cell} python +N = 1000 +params = LoanRepaymentParams(0.05, 50_000.0, 25_000) +print(np.mean(simulate_loan_repayments_2(N, params))) +``` + +One benefit of using a class is that you can do calculations in the constructor. For example, instead of passing in the partial repayment amount, we could pass in the fraction of the full repayment that is paid. + +```{code-cell} python +class LoanRepaymentParams2: + def __init__(self, r, repayment_full, partial_fraction = 0.3): + self.r = r + self.repayment_full = repayment_full + + # This does a calculation and sets a new value + self.repayment_part = repayment_full * partial_fraction + +# Create an instance of the class +params = LoanRepaymentParams2(0.05, 50_000.0, 0.5) +print(params.repayment_part) # Acccess the calculation +print(np.mean(simulate_loan_repayments_2(N, params))) +``` + +This setup a default value for the `partial_fraction` so that we could also have called this with `LoanRepaymentParams2(0.05, 50_000)`. + + +Finally, there are some special features we can use to create classes in python which automatically create the `__init__` function, allow for more easily setting default values. The easiest is to create a `dataclass` (see [documentation](https://docs.python.org/3/library/dataclasses.html)). + +```{code-cell} python +from dataclasses import dataclass + +@dataclass +class LoanRepaymentParams3: + r: float = 0.05 + repayment_full: float = 50_000 + repayment_part: float = 25_000 + +params = LoanRepaymentParams3() # uses all defaults +params2 = LoanRepaymentParams3(repayment_full= 60_000) # changes the full repayment amount + +# show the objects +print(params) +print(params2) + +# simulate using the new object +print(np.mean(simulate_loan_repayments_2(N, params2))) +``` + +The `@dataclass` is an example of a python decorator (see [documentation](https://docs.python.org/3/glossary.html#term-decorator)). Decorators take in a class (or function) and return a new class (or function) with some additional features. In this case, it automatically creates the `__init__` function, allows for default values, and adds a new `__repr__` function which determines how the object is printed. + #### Profitability Threshold Rather than looking for the break even point, we might be interested in the largest loan size that