diff --git a/Labs/Lab3/big-o/1.png b/Labs/Lab3/big-o/1.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a079607c702755314ec4b53244eae5a686c5da0
Binary files /dev/null and b/Labs/Lab3/big-o/1.png differ
diff --git a/Labs/Lab3/big-o/2.png b/Labs/Lab3/big-o/2.png
new file mode 100644
index 0000000000000000000000000000000000000000..c3594ccafc50c6957ccabff8531a5216bfd6c757
Binary files /dev/null and b/Labs/Lab3/big-o/2.png differ
diff --git a/Labs/Lab3/big-o/3.png b/Labs/Lab3/big-o/3.png
new file mode 100644
index 0000000000000000000000000000000000000000..2dc1d074544d3089e8ca3e4d85f9dc0af63fdad6
Binary files /dev/null and b/Labs/Lab3/big-o/3.png differ
diff --git a/Labs/Lab3/big-o/4.png b/Labs/Lab3/big-o/4.png
new file mode 100644
index 0000000000000000000000000000000000000000..3dedad9174261e6d1b905601e4ed8b9cd8c1d8ce
Binary files /dev/null and b/Labs/Lab3/big-o/4.png differ
diff --git a/Labs/Lab3/big-o/README.md b/Labs/Lab3/big-o/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0067fd11900c64196529830f1892a9d250eb5634
--- /dev/null
+++ b/Labs/Lab3/big-o/README.md
@@ -0,0 +1,218 @@
+# Visualizing Order of Growth
+
+In this part, you'll get to visually experiment with different C
+values and lower bounds on N in order to show that a function fits a
+certain order of growth.  To start, do some imports:
+
+```python
+import pandas as pd
+import matplotlib
+from matplotlib import pyplot as plt
+from math import log2, log10, ceil
+```
+
+Also remember to do `%matplotlib inline`
+
+Now paste the following code in a cell (you don't need to understand it for the purposes of this lab):
+
+```python
+matplotlib.rcParams["font.size"] = 20
+
+def get_ax():
+    fig, ax = plt.subplots(figsize=(8,6))
+    ax.spines["right"].set_visible(False)
+    ax.spines["top"].set_visible(False)
+    ax.set_xlim(1, 10)
+    return ax
+
+def scale_ax():
+    ax = get_ax()
+    ax.set_xlabel("N (data size)")
+    ax.set_ylabel("Steps")
+    return ax
+
+def plot_func(ax, f, C=1, color="k", label="work"):
+    start = ax.get_xlim()[0]
+    width = ax.get_xlim()[1] - ax.get_xlim()[0]
+    s = pd.Series([],dtype=float)
+    for i in range(100):
+        N = start + width * (i+1)/100
+        s[N] = eval(f)
+    s.sort_index().plot(ax=ax, color=color, linewidth=3, label=label)
+    plt.text(s.index[-1], s.iloc[-1], f, verticalalignment='center')
+    
+def upper_bound(ax, order, C=1, minN=None):
+    f = order
+    if C != 1:
+        f = "C * (%s)" % order
+    plot_func(ax, f, C=C, color="r", label="upper bound")
+    if minN != None:
+        ax.axvspan(minN, ax.get_xlim()[1], color='0.85')
+    ax.legend(frameon=False)
+```
+
+## Exercise 1: show `N+100` is in `O(N)`.
+
+You need to show that some multiple of `N` is an upper bound on
+`N+100` for large values of `N`.  Paste+run this code:
+
+```python
+ax = scale_ax()
+ax.set_xlim(0, 10) # TODO: change upper bound
+plot_func(ax, "N + 100")
+
+upper_bound(ax, order="N") # TODO: pass C and minN
+```
+
+It should look like this:
+
+<img src="1.png">
+
+Clearly `g(N)=N` is not an upper bound on `f(N)=N+100`, but remember
+that you are allowed to do the following:
+
+1. multiple `g(N)` by a constant `C`
+2. require that `N` be large
+
+To use your first capability, pass in a `C` value of of 25, changing the call to `upper_bound(...)` to be like this:
+
+```python
+upper_bound(ax, order="N", C=25)
+```
+
+It should look like this:
+
+<img src="2.png">
+
+Better, but now the red line is only an upper bound for large `N`
+values.  Let's set a lower bound on `N`, like this:
+
+```python3
+upper_bound(ax, order="N", C=25, minN=6)
+```
+
+It should look like this:
+
+<img src="3.png">
+
+Great!
+
+<b>In general for all of these exercises, your job is to choose `C` and
+  `minN` values so that the red line is above the black line in the
+  shaded portion.</b>
+
+Finally, you should make sure the upper bound keeps holding for large
+N values.  If this were a math course, you would prove this is true
+for all large N values.  But since this is a programming course, let's
+just make the `xlim` really big and make sure there are no obvious
+problems, like this:
+
+```python
+ax.set_xlim(0, 1e6) # 1 million
+```
+
+Looks good!
+
+<img src="4.png">
+
+## Exercise 2: show `100*N` is in `O(N)`.
+
+Start with this:
+
+```python
+ax = scale_ax()
+ax.set_xlim(0, 10)
+plot_func(ax, "100*N")
+
+upper_bound(ax, order="N")
+```
+
+Do you need to set `minN` for this one, or is choosing the right `C` good enough?
+
+## Exercise 3: show `N**2 + 5*N + 25` is in `O(N**2)`
+
+Start with this:
+
+```python
+ax = scale_ax()
+ax.set_xlim(0, 10)
+plot_func(ax, "N**2 + 5*N + 25")
+
+upper_bound(ax, order="N**2")
+```
+
+## Exercise 4: *try* to show `2*N + (N/15)**4` is in `O(N)`
+
+Start with this:
+
+```python
+ax = scale_ax()
+ax.set_xlim(0, 10)
+plot_func(ax, "2*N + (N/15)**4")
+
+upper_bound(ax, order="N")
+```
+
+Use `C=3` and `minN=1`.
+
+What happens when you increase the x limit to 75?
+
+```python
+ax.set_xlim(0, 75)
+```
+
+It turns out `2*N + (N/15)**4` is NOT in `O(N)` after all.  What
+Big O order of growth does `2*N + (N/15)**4` have?
+
+## Exercise 5: show `log2(N)` is in `O(log10(N))`
+
+Start with this:
+
+```python
+ax = scale_ax()
+ax.set_xlim(1, 100)
+plot_func(ax, "log2(N)")
+
+upper_bound(ax, order="log10(N)")
+```
+
+Any `C` value that is at least `log2(10)` will work here.  In general,
+any log curve with base M is just a constant multiple of another log
+curve with base N.  Thus, it is common to just say an algorithm is
+`O(log N)`, without bothering to specify the base.
+
+## Exercise 6: show `ceil(log2(N))` is in `O(log N)`
+
+Start with this:
+
+```python
+ax = scale_ax()
+ax.set_xlim(1, 100)
+plot_func(ax, "ceil(log2(N))")
+
+upper_bound(ax, order="log2(N)")
+```
+
+## Exercise 7: show `N * ceil(log2(N))` is in `O(N log N)`
+
+Start with this:
+
+```python
+ax = scale_ax()
+ax.set_xlim(1, 100)
+plot_func(ax, "N * ceil(log2(N))")
+
+upper_bound(ax, order="N * log2(N)")
+```
+
+## Exercise 8: show `F(N) = 0+1+2+...+(N-1)` is in `O(N**2)`
+
+Replace `????` in the following:
+
+```python
+ax = scale_ax()
+ax.set_xlim(1, 100)
+plot_func(ax, "sum(range(int(N)))")
+
+upper_bound(ax, order=????)
+```
diff --git a/Labs/Lab3/files-json/1.png b/Labs/Lab3/files-json/1.png
new file mode 100644
index 0000000000000000000000000000000000000000..e0013025e9a1367a3facb481d1923b166ce9f409
Binary files /dev/null and b/Labs/Lab3/files-json/1.png differ
diff --git a/Labs/Lab3/files-json/2.png b/Labs/Lab3/files-json/2.png
new file mode 100644
index 0000000000000000000000000000000000000000..f4091530dcf5748dda88ca8dc7c5c5b1c2b68e07
Binary files /dev/null and b/Labs/Lab3/files-json/2.png differ
diff --git a/Labs/Lab3/files-json/README.md b/Labs/Lab3/files-json/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..252f21238e08b682db988fafeee59a0fbca5eae4
--- /dev/null
+++ b/Labs/Lab3/files-json/README.md
@@ -0,0 +1,134 @@
+# Files and JSON
+
+## Writing
+
+Let's try writing a file.  Paste the following and run it (it's buggy!):
+
+```python
+f = open("file.txt")
+f.write("line1")
+f.write("line2")
+f.close() # you need the parentheses, even without arguments!
+```
+
+The code fails because the file wasn't opened in write mode.  Add "w"
+as a second positional arguments to `open`, then try again.
+
+Does the above code actually produce two lines?  Open `file.txt`
+through Jupyter and check.  Try modifying the "line1" and "line2"
+strings to get two different lines, so that `file.txt` looks like this:
+
+```
+line_1
+line_2
+```
+
+## Reading
+
+Create a file named `dog.json` in Jupyter.  You can right click in the file browser to create a new file:
+
+<img src="1.png" width=400>
+
+Copy the following:
+
+```json
+{
+    "name": "Fido",
+    "age": 1
+}
+```
+
+Paste it into `dog.json` (which you should open with the "Editor"), and be sure to SAVE it:
+
+<img src="2.png" width=600>
+
+Now, back in your notebook, run this:
+
+```python
+f = open("dog.json")
+input1 = f.read()
+input2 = f.read()
+f.close()
+```
+
+What is `type(input1)`?
+
+Print out `input1`, then print `input2`.  Notice that `input2` is the
+empty string?  That's because the first read call consumes all the
+input, and there's nothing left for the second read call.  To get the
+data again, you could re-open the file, then re-read it again.
+
+Note that `f` is a file object.  Calling `.read` is only one way to
+get the contents.  File objects are iterators, meaning that you could also loop over `f` -- try it:
+
+```python
+f = open("dog.json")
+for line in f:
+    print("LINE: " + line, end="")
+f.close()
+```
+
+You can also create lists from iterators.  Try that:
+
+```python
+f = open("dog.json")
+lines = list(f)
+f.close()
+
+print("GOT", len(lines), "lines")
+```
+
+## `with`
+
+It's easy to forget to close a file.  Python has a special `with`
+statement that can do it automatically for you.  This:
+
+```python
+f = open("dog.json")
+lines = list(f)
+f.close()
+```
+
+Is equivalent to this (try it):
+
+```python
+with open("dog.json") as f:
+    lines = list(f)
+# f is automatically closed after the with block
+```
+
+## JSON
+
+Your data may be in the following forms:
+
+1. a file object
+2. a string
+3. a dict (or other Python data type)
+
+`json.load` converts from (1) to (3).  `json.loads` converts from (2)
+to (3).  Fix the following code so it uses the appropriate load
+function:
+
+```python
+import json
+
+with open("dog.json") as f:
+    dog = json.loads(f) # fixme
+```
+
+Check that `type(dog)` is a `dict`.
+
+Now fix this one too:
+
+```python
+data = '{"name": "Fido", "age": 1}'
+dog = json.load(data) # fixme
+```
+
+And one more:
+
+```python
+with open("dog.json") as f:
+    data = f.read()
+    dog = json.load(data) # fixme
+```
diff --git a/Labs/Lab3/files-zip/README.md b/Labs/Lab3/files-zip/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..dfa310c95b0cd26920ffbd1a0cfb3d65995fcc1c
--- /dev/null
+++ b/Labs/Lab3/files-zip/README.md
@@ -0,0 +1,163 @@
+# Zip Files
+
+As you deal with bigger datasets, those datasets will often be
+compressed.  Compressed means that the format takes advantage of
+patterns and redundancy in data to store a bigger file in less space.
+
+For example, say you have a string like this: "HAHAHAHAHAHAHAHAHAHA".
+You should imagine inventing a notation for representing that string
+with fewer characters (maybe something like "HA{x10}").
+
+Zip is one common compression format.  In addition to compressing
+files, .zips often bundle multiple files together.  In the past, you
+would have run `unzip` in the terminal before starting to write your
+code.  However, it is also possible to directly read the contents of a
+`.zip` file in Python.  Doing so is often more convenient; the code
+may also quite possibly be faster.
+
+## Generating a .zip
+
+To create an `example.zip` file, run the following (don't worry,
+understanding this particular snippet isn't expected for this lab):
+
+```python
+import pandas as pd
+from zipfile import ZipFile, ZIP_DEFLATED
+from io import TextIOWrapper
+
+with open("hello.txt", "w") as f:
+    f.write("hello world")
+
+with ZipFile("example.zip", "w", compression=ZIP_DEFLATED) as zf:
+    with zf.open("hello.txt", "w") as f:
+        f.write(bytes("hello world", "utf-8"))
+    with zf.open("ha.txt", "w") as f:
+        f.write(bytes("ha"*10000, "utf-8"))
+    with zf.open("bugs.csv", "w") as f:
+        pd.DataFrame([["Mon",7], ["Tue",4], ["Wed",3], ["Thu",6], ["Fri",9]],
+                     columns=["day", "bugs"]).to_csv(TextIOWrapper(f), index=False)
+```
+
+## ZipFile
+
+We can access the file by using the `ZipFile` type, imported from the `zipfile` module:
+
+```python
+from zipfile import ZipFile
+```
+
+ZipFiles are context managers, much like file objects.  Let's try
+creating one using `with`, then loop over info about the files inside
+using [this
+method](https://docs.python.org/3/library/zipfile.html#zipfile.ZipFile.infolist):
+
+```python
+with ZipFile('example.zip') as zf:
+    for info in zf.infolist():
+        print(info)
+```
+
+Let's print off the size and compression ratio (uncompressed size divided by compressed size) of each file:
+
+```python
+with ZipFile('example.zip') as zf:
+    for info in zf.infolist():
+        orig_mb = info.file_size / (1024**2) # there are 1024**2 bytes in a MB
+        ratio = info.file_size / info.compress_size
+        s = "file {name:s}, {mb:.3f} MB (uncompressed), {ratio:.1f} compression ratio"
+        print(s.format(name=info.filename, mb=orig_mb, ratio=ratio))
+```
+
+Take a minute to look through -- which file is largest?  What is its
+compression ratio?
+
+The compression ratio is the original size divided by the compressed
+size, so bigger means more savings.  `ha.txt` contains "hahahahaha..."
+(repeated 10 thousand times), which is highly compressible.
+
+As practice, compute the overall compression ration (sum of all
+uncompressed sizes divided by sum of all compressed sizes) -- it ought
+to be about 216.
+
+## Binary Open
+
+Ok, forget zips for a minute, and run the following:
+
+```python
+with open("hello.txt", "r") as f:
+    data1 = f.read()
+
+with open("hello.txt", "rb") as f:
+    data2 = f.read()
+
+print(type(data1), type(data2))
+```
+
+What type does `f.read()` return if we use "r" for the mode?  What
+about "rb"?
+
+The "b" stands for "binary" or "bytes", so we get back type `bytes`.
+If we open in text mode (the default), as in the first open, the bytes
+automatically get translated to strings, using some encoding (like
+"utf-8") that assigns characters to byte-represented numbers.
+
+Run this:
+
+```python
+from io import TextIOWrapper
+```
+
+`TextIOWrapper` objects "wrap" file objects are used to convert bytes
+to characters on the fly.  For example, try the following:
+
+```python
+with open("hello.txt", "rb") as f:
+    tio = TextIOWrapper(f)
+    data3 = tio.read()
+print(type(data3))
+```
+
+Even though we open in binary mode, we get a string thanks to
+`TextIOWrapper`!  You can think of the example where we read into
+`data1` as a shorthand for what we did to get `data3`.
+
+## Reading Files
+
+A ZipFile has a method named `open` that works a lot like the `open`
+function you're familiar with.  A ZipFile is a context manager, and so
+is the object returned by `ZipFile.open(...)`, so we'll end up with
+nested `with` statements to make sure everything gets closed up
+properly.  Let's take a look at the compressed schedule file:
+
+```python
+with ZipFile('example.zip') as zf:
+    with zf.open("hello.txt", "r") as f:
+        print(f.read())
+```
+
+Woah, why do we get `b'hello world'`?  For regular files, "r" mode
+defaults to reading text, but for files inside a zip, it defaults to
+binary mode, so we got back bytes.
+
+TextIOWrapper saves the day:
+
+```python
+with ZipFile('example.zip') as zf:
+    with zf.open("hello.txt", "r") as f:
+        tio = TextIOWrapper(f)
+        print(tio.read())
+```
+
+With regular files, TextIOWrapper is a bit useless (why not just open
+with "r" instead of "rb"?), but for zips, it is crucial.
+
+## Pandas
+
+Pandas can read a DataFrame even from a binary stream.  So you can can do this:
+
+```python
+with ZipFile('example.zip') as zf:
+    with zf.open("bugs.csv") as f:
+         df = pd.read_csv(f)
+df
+```
diff --git a/Labs/Lab3/lab3.md b/Labs/Lab3/lab3.md
new file mode 100644
index 0000000000000000000000000000000000000000..75495781ec18fe48cc33a4cb0754e7c983ec167a
--- /dev/null
+++ b/Labs/Lab3/lab3.md
@@ -0,0 +1,11 @@
+# Lab 3: Files
+
+1. Share with your group: *What is your favorite day of the year?*
+
+2. Practice [visual complexity analysis](./big-o)
+
+3. Practice Python [files and JSON](./files-json)
+
+4. Learn how to read [zip files](./files-zip) in Python
+
+5. Start the [loan.py](./loans) module that will help you complete P2
\ No newline at end of file
diff --git a/Labs/Lab3/loans/README.md b/Labs/Lab3/loans/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..99a23fac4590ad1b05384d850c37092a24303eb0
--- /dev/null
+++ b/Labs/Lab3/loans/README.md
@@ -0,0 +1,305 @@
+# Loan Module
+
+In these exercises, you'll start writing a `loans.py` module with two
+Python classes you'll use for P2.  It's OK if you don't finish these
+classes during lab time (you can finish them with your group or alone
+later when working on P2).
+
+## loans.py
+
+In Jupyter, do the following:
+1. Go to P2
+2. Right click in the file explore and create a "New File"
+3. Name it loans.py
+4. Open it
+
+Using a .py module is easy -- just run `import some_mod` to run
+`some_mod.py`, loading any function or classes it has.
+
+In your `loans.py`, add a print like this:
+
+```python
+print("Hello from loans.py!")
+
+def hey():
+    print("Hey!")
+```
+
+Now lets import it to your project notebook.  Create a `p2.ipynb` in
+the same directory as `loans.py`.
+
+Run `import loans` in a cell.  You should see the first print!
+
+You can also call the `hey` function now.  Try it:
+
+```python
+loans.hey()
+```
+
+If you change `hey` in `loans.py`, the new version won't automatically
+reload into the notebook.  Add this so it will auto-reload:
+
+```
+%load_ext autoreload
+%autoreload 2
+```
+
+Note this doesn't work all the time (if there's a bug in your
+loans.py, you may need to do a Restart & Run All in the notebook after
+fixing your module).
+
+Feel free to delete the print statement and `hey` method from
+`loans.py` (those were just for experimentation -- we'll be adding
+other content to `loans.py`).
+
+## 1. `Applicant` class
+
+We'll want to create a class to represent people who apply for loans.  Start with this in `loans.py`:
+
+```python
+class Applicant:
+    def __init__(self, age, race):
+        self.age = age
+        self.race = set()
+        for r in race:
+            ????
+```
+
+We'll be using HDMA loan data
+(https://www.ffiec.gov/hmda/pdf/2023guide.pdf), which uses numeric
+codes to represent race.  Here are the codes from the documentation,
+recorded in a dictionary:
+
+```python
+race_lookup = {
+    "1": "American Indian or Alaska Native",
+    "2": "Asian",
+    "3": "Black or African American",
+    "4": "Native Hawaiian or Other Pacific Islander",
+    "5": "White",
+    "21": "Asian Indian",
+    "22": "Chinese",
+    "23": "Filipino",
+    "24": "Japanese",
+    "25": "Korean",
+    "26": "Vietnamese",
+    "27": "Other Asian",
+    "41": "Native Hawaiian",
+    "42": "Guamanian or Chamorro",
+    "43": "Samoan",
+    "44": "Other Pacific Islander"
+}
+```
+
+Paste the `dict` in your `loans.py` module, and use it to complete
+your `__init__` constructor.  The loop should add entries in the
+`race` parameter to the `self.race` attribute of the classes,
+converting from the numeric codes to text in the process.  The `race`
+attribute is a set because applicants often identify with multiple
+options.
+
+Simply skip over any entries in the `race` parameter that don't appear
+in the `race_lookup` dict (e.g., we'll see and skip "6" later because
+that code indicates a missing value).
+
+Test the code you wrote in `loans.py` from your `p2.ipynb` notebook to
+make sure the `Applicant.__init__` constructor properly fills the
+`race` set.
+
+```python
+applicant = loans.Applicant("20-30", ["1", "2", "3"])
+applicant.race
+```
+
+You should see this set:
+
+```python
+{'American Indian or Alaska Native', 'Asian', 'Black or African American'}
+```
+
+### `__repr__`
+
+Add a `__repr__` method to your `Applicant` class:
+
+```python
+    def __repr__(self):
+        ????
+        return ????
+```
+
+Putting `applicant` at the end of a cell or printing `repr(applicant)` should show this:
+
+```
+Applicant('20-30', ['American Indian or Alaska Native', 'Asian', 'Black or African American'])
+```
+
+Note: The `race` attribute should be sorted lexicographically.
+
+### `lower_age`
+
+You might notice that ages are given as strings rather than ints
+because we need to support ranges (like "20-30").
+
+Add a `lower_age` method that returns the lower int of an applicant's age range:
+
+```python
+    def lower_age(self):
+        return ????
+```
+
+It should also support ages like "<75" (should just return the int
+`75`) and ">25" (should just return the int `25`).
+
+Try your method (you should get the int `20` since the age is "20-30"):
+
+```python
+applicant.lower_age()
+```
+
+Hints: you could use `.replace` get get rid of unhelpful characters
+(like "<" and ">").  After that, splitting on "-" could help you find
+the first number (it's OK to split on a character that doesn't appear
+in a string -- you just get a list with one entry).
+
+### `__lt__`
+
+Recall that `__lt__` ("less than") lets you control what happens when
+two objects get compared.
+
+`obj1 < obj2` automatically becomes `obj1.__lt__(obj2)`, so you can
+write `__lt__` to return a True/False, indicating whether `obj1` is
+less.
+
+Complete the following for your `Applicant` class:
+
+```python
+    def __lt__(self, other):
+        return ????
+```
+
+Comparisons should be based on age.  Python sorting will also use your
+`__lt__` method.  Try it:
+
+```python
+sorted([
+    loans.Applicant(">75", ["43", "44"]),
+    loans.Applicant("20-30", ["1", "3"]),
+    loans.Applicant("35-44", ["22"]),
+    loans.Applicant("<25", ["5"]),
+])
+```
+
+You should get this order:
+
+```python
+[Applicant('20-30', ['American Indian or Alaska Native', 'Black or African American']),
+ Applicant('<25', ['White']),
+ Applicant('35-44', ['Chinese']),
+ Applicant('>75', ['Other Pacific Islander', 'Samoan'])]
+```
+
+## 2. `Loan` class
+
+For the project, we'll use data loan data from this site:
+https://cfpb.github.io/hmda-platform/#hmda-api-documentation.
+
+Loan applications are described with dictionaries, like this:
+
+```python
+values = {'activity_year': '2021', 'lei': '549300Q76VHK6FGPX546', 'derived_msa-md': '24580', 'state_code': 'WI','county_code': '55009', 'census_tract': '55009020702', 'conforming_loan_limit': 'C', 'derived_loan_product_type': 'Conventional:First Lien', 'derived_dwelling_category': 'Single Family (1-4 Units):Site-Built', 'derived_ethnicity': 'Not Hispanic or Latino', 'derived_race': 'White', 'derived_sex': 'Joint', 'action_taken': '1', 'purchaser_type': '1', 'preapproval': '2', 'loan_type': '1', 'loan_purpose': '31', 'lien_status': '1', 'reverse_mortgage': '2', 'open-end_line_of_credit': '2', 'business_or_commercial_purpose': '2', 'loan_amount': '325000.0', 'loan_to_value_ratio': '73.409', 'interest_rate': '2.5', 'rate_spread': '0.304', 'hoepa_status': '2', 'total_loan_costs': '3932.75', 'total_points_and_fees': 'NA', 'origination_charges': '3117.5', 'discount_points': '', 'lender_credits': '', 'loan_term': '240', 'prepayment_penalty_term': 'NA', 'intro_rate_period': 'NA', 'negative_amortization': '2', 'interest_only_payment': '2', 'balloon_payment': '2', 'other_nonamortizing_features': '2', 'property_value': '445000', 'construction_method': '1', 'occupancy_type': '1', 'manufactured_home_secured_property_type': '3', 'manufactured_home_land_property_interest': '5', 'total_units': '1', 'multifamily_affordable_units': 'NA', 'income': '264', 'debt_to_income_ratio': '20%-<30%', 'applicant_credit_score_type': '2', 'co-applicant_credit_score_type': '9', 'applicant_ethnicity-1': '2', 'applicant_ethnicity-2': '', 'applicant_ethnicity-3': '', 'applicant_ethnicity-4': '', 'applicant_ethnicity-5': '', 'co-applicant_ethnicity-1': '2', 'co-applicant_ethnicity-2': '', 'co-applicant_ethnicity-3': '', 'co-applicant_ethnicity-4': '', 'co-applicant_ethnicity-5': '', 'applicant_ethnicity_observed': '2', 'co-applicant_ethnicity_observed': '2', 'applicant_race-1': '5', 'applicant_race-2': '', 'applicant_race-3': '', 'applicant_race-4': '', 'applicant_race-5': '', 'co-applicant_race-1': '5', 'co-applicant_race-2': '', 'co-applicant_race-3': '', 'co-applicant_race-4': '', 'co-applicant_race-5': '', 'applicant_race_observed': '2', 'co-applicant_race_observed': '2', 'applicant_sex': '1', 'co-applicant_sex': '2', 'applicant_sex_observed': '2', 'co-applicant_sex_observed': '2', 'applicant_age': '35-44', 'co-applicant_age': '35-44', 'applicant_age_above_62': 'No', 'co-applicant_age_above_62': 'No', 'submission_of_application': '1', 'initially_payable_to_institution': '1', 'aus-1': '1', 'aus-2': '', 'aus-3': '', 'aus-4': '', 'aus-5': '', 'denial_reason-1': '10', 'denial_reason-2': '', 'denial_reason-3': '', 'denial_reason-4': '', 'tract_population': '6839', 'tract_minority_population_percent': '8.85999999999999943', 'ffiec_msa_md_median_family_income': '80100', 'tract_to_msa_income_percentage': '150', 'tract_owner_occupied_units': '1701', 'tract_one_to_four_family_homes': '2056', 'tract_median_age_of_housing_units': '15'}
+```
+
+Paste the above to your notebook.  We want to use a dict like the above to create a `Loan` object as follows:
+
+```python
+loan = loans.Loan(values)
+```
+
+Whereas the `__init__` for `Applicant` took a few parameters, the
+`__init__` for the `Loan` class will take a single parameter,
+`values`, which will contain all the data necessary to set the `Loan`
+attributes.
+
+Start with the following, then modify and add code:
+
+```python
+class Loan:
+    def __init__(????, values):
+        self.loan_amount = values["loan_amount"]
+        # add lines here
+```
+
+Requirements:
+* a `Loan` object should have four attributes: `loan_amount`, `property_value`, `interest_rate`, `applicants`
+* the first three attributes are floats (you'll need to convert from the strings found in `values`)
+* strings like "NA" and "Exempt" that represent missing values can be `-1` when you convert to floats
+* the `applicants` attribute should be a list of `Applicant` objects.  Every loan has at least one applicant, with age `values["applicant_age"]` and race(s) in the multiple `values["applicant_race-????"]` entries.
+* some loans have a second applicant (but no more) -- you'll know there is a second applicant when `values["co-applicant_age"] != "9999"`.  In that case, `self.applicants` should contain two `Applicant` objects, with the info from the second coming from the `values["co-applicant_age"]` and `values["co-applicant_race-????"]` entries.
+
+Manually test your `Loan` class from your notebook with a few snippets:
+* `loan.interest_rate` should be `2.5`
+* `loan.applicants` should be `[Applicant('35-44', ['White']), Applicant('35-44', ['White'])]`
+* choose a couple more...
+
+### `__str__` and `__repr__`
+
+Add a `__str__` method to your `Loan` class so that `print(loan)` gives the following:
+
+```
+<Loan: 2.5% on $445000.0 with 2 applicant(s)>
+```
+
+Add a `__repr__` that returns the same string as `__str__`.
+
+### `yearly_amounts`
+
+The loans have details regarding payment amount and frequency in the
+terms, but for simplicity, we'll ignore that here.
+
+The `yearly_amounts` method in the `Loan` class should be a generator
+that yields loan amounts, as the loan is payed off over time.  Assume
+that each year, a single payment is made, after interest is
+calculated. **Note:** `loan.interest_rate` is in percentage. Convert it
+to decimal before using it. 
+
+```python
+def yearly_amounts(self, yearly_payment):
+    # TODO: assert interest and amount are positive
+    result = []
+    amt = self.loan_amount
+
+    while amt > 0:
+        result.append(amt)
+        # TODO: add interest rate multiplied by amt to amt
+        # TODO: subtract yearly payment from amt
+    return result
+```
+
+Your job:
+1. Finish the TODOs
+2. Test your code from the notebook.  For example, you could run this from the notebook:
+
+```python
+for amt in loan.yearly_amounts(30000):
+    print(amt)
+```
+
+And get this:
+
+```
+325000.0
+303125.0
+280703.125
+257720.703125
+234163.720703125
+210017.81372070312
+185268.2590637207
+159899.96554031371
+133897.46467882156
+107244.90129579211
+79926.02382818691
+51924.174423891585
+23222.278784488873
+```
+
+3. Make the method a generator.  Get rid of the `result` list, and instead of appending to it, yield `amt`.  Make sure the loop works the same way as before in your notebook.  One advantage of the generator is that the method will work even if the payment is too small (the generator will keep yielding larger amounts as the debt keeps growing). **That last step is very important to passing the P2 tests!**