Skip to content

Commit

Permalink
Merge branch 'main' into base64-simplification
Browse files Browse the repository at this point in the history
  • Loading branch information
Bluesy1 authored Nov 22, 2023
2 parents 8c1e372 + ae2286a commit 9b55dad
Show file tree
Hide file tree
Showing 8 changed files with 1,296 additions and 149 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/on-release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Push Updated Problem Bank Helpers to Dependent Repos

on:
workflow_dispatch:
release:
types: [published]

Expand All @@ -21,7 +22,7 @@ jobs:
strategy:
fail-fast: false # if one repo fails, continue with the others, it might be unrelated
matrix:
repo: [PrairieLearnUBC/pl-ubc-opb000, PrairieLearnUBC/pl-ubc-opb100] # add more repos here
repo: [PrairieLearnUBC/pl-ubc-opb000, PrairieLearnUBC/pl-ubc-opb100, PrairieLearnUBC/pl-ubc-phys111, PrairieLearnUBC/pl-ubc-phys112, PrairieLearnUBC/pl-ubc-phys121,PrairieLearnUBC/pl-ubc-phys122, PrairieLearnUBC/pl-ubc-apsc181] # add more repos here
# if the token needs to be different per repo, add a token matrix using the extend matrix syntax
# as shown here: https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs#example-expanding-configurations
steps:
Expand Down
429 changes: 429 additions & 0 deletions notebook_features/shaded_normal_density_curve.ipynb

Large diffs are not rendered by default.

762 changes: 621 additions & 141 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "problem_bank_helpers"
version = "0.2.2"
version = "0.2.5"
description = "Helpful utilities for the open problem bank."
authors = ["Firas Moosvi and Jake Bobowski"]
license = "MIT"
Expand All @@ -11,10 +11,12 @@ repository = "https://github.com/open-resources/problem_bank_helpers"
include = [{ path = "data/", format = ["sdist", "wheel"]}]

[tool.poetry.dependencies]
python = "^3.10"
python = ">=3.10,<3.13"
sigfig = "^1.1.9"
numpy = "^1.20.3"
pandas = "^2.0.0"
matplotlib = "^3.8.1"
scipy = "^1.11.3"

[tool.poetry.dev-dependencies]
pytest = "^6.2.4"
Expand Down
3 changes: 2 additions & 1 deletion src/problem_bank_helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__version__ = "0.2.2"
__version__ = "0.2.5"

from .problem_bank_helpers import *
from . import stats # Keep stats as a separate namespace, but also make it accessible from the top level without an explicit import
32 changes: 29 additions & 3 deletions src/problem_bank_helpers/problem_bank_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ def roundp(*args,**kwargs):
num_str = str(float(a[0]))

# Create default sigfigs if necessary
if kw.get('sigfigs',None):
if kw.get('sigfigs',None) != None:
z = kw['sigfigs']
elif kw.get('decimals', None):
elif kw.get('decimals', None) != None:
z = kw['decimals']
else:
z = 3 # Default sig figs
Expand All @@ -214,7 +214,6 @@ def roundp(*args,**kwargs):
# sigfig.round doesn't like zero
if abs(float(num_str)) == 0:
result = num_str
print("num is zero: " + result + "\n")
else:
result = sigfig.round(num_str,**kwargs)

Expand Down Expand Up @@ -428,3 +427,30 @@ def choose_el(x, i, j):
html += "\n</tr>"
html += "\n</table>"
return html

def template_mc(data, part_num, choices):
"""
Adds multiple choice to data from dictionary
Args:
choices (dict): the multiple-choice dictionary
Example:
options = {
'option1 goes here': ['correct', 'Nice work!'],
'option2 goes here': ['Incorrect', 'Incorrect, try again!'],
....
}
template_mc(data2, 1, options)
"""
for i, (key, value) in enumerate(choices.items()):
data['params'][f'part{part_num}'][f'ans{i+1}']['value'] = key
is_correct = value[0].strip().lower() == 'correct'
data['params'][f'part{part_num}'][f'ans{i+1}']['correct'] = is_correct

try:
data['params'][f'part{part_num}'][f'ans{i+1}']['feedback'] = value[1]
except IndexError:
data['params'][f'part{part_num}'][f'ans{i+1}']['feedback'] = "Feedback is not available"
208 changes: 208 additions & 0 deletions src/problem_bank_helpers/stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Author: Firas Moosvi, Jake Bobowski, others
# Date: 2023-10-31

from __future__ import annotations

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from scipy import stats


def shaded_normal_density(
q: float | tuple[float, float],
/,
mean: float = 0,
sd: float = 1,
rsd: float = 4,
lower_tail: bool = True,
add_prob: bool = True,
add_q: bool = True,
add_legend: bool = False,
figsize: tuple[float, float] | None = (8, 6),
color: str = "xkcd:sky blue",
x_label: str = "x",
y_label: str = "f(x; μ,σ)",
legend_text: str | None = None,
**kwargs,
) -> Figure:
"""
Generate a normal distribution plot with optional listed probability calculation.
Parameters
----------
q : float or tuple of 2 floats
If a float, the upper or lower bound of the shaded area. If a tuple of floats, the lower and upper bounds of the shaded area.
mean : float, default: 0
The mean of the normal distribution.
sd : float, default: 1
The standard deviation of the normal distribution.
rsd : float, default: 4
The number of standard deviations to plot on either side of the mean=.
lower_tail : bool, default: True
Whether the shaded area should represent the lower tail probability P(X <= x) (True) or the upper tail probability P(X > x) (False).
add_prob : bool, default: True
Whether to show the probability of the shaded area will be displayed on the plot.
add_q : bool, default: True
Whether the value(s) of `q` should be displayed on the x-axis of the plot.
add_legend : bool, default: False
Whether a legend with the mean and standard deviation values will be displayed on the plot.
figsize : tuple of 2 floats or None, default: (8, 6)
The size of the plot in inches. If None, the default matplotlib figure size will be used as this is passed to `matplotlib.pyplot.figure`.
color : color, default: 'xkcd:sky blue'
The color of the shaded area as a valid `matplotlib color <https://matplotlib.org/stable/users/explain/colors/colors.html>`__.
x_label : str, default: 'x'
The label for the x-axis.
y_label : str, default: 'f(x; μ,σ)'
The label for the y-axis.
legend_text : str or None, optional
The text to display in the legend if add_legend is set to true. By default (None), the legend will display the mean and standard deviation values.
**kwargs
Additional keyword arguments to pass to `matplotlib.pyplot.figure`.
Returns
-------
matplotlib.figure.Figure
The generated matplotlib Figure object.
Raises
------
TypeError
If the input parameters are not of the expected type.
ValueError
If the input values are out of the expected range.
References
----------
Based off of an R function written by Dr. Irene Vrbick for making `shaded normal density curves <https://irene.vrbik.ok.ubc.ca/blog/2021-11-04-shading-under-the-normal-curve/>`__.
The R function by Dr. Irene Vrbick was adapted from `here <http://rstudio-pubs-static.s3.amazonaws.com/78857_86c2403ca9c146ba8fcdcda79c3f4738.html>`__.
"""
if not isinstance(mean, (float, int)):
raise TypeError(f"mean must be a number, not a {mean.__class__.__name__!r}")
if not isinstance(sd, (float, int)):
raise TypeError(f"sd must be a number, not a {sd.__class__.__name__!r}")
if not isinstance(rsd, (float, int)):
raise TypeError(f"rsd must be a number, not a {rsd.__class__.__name__!r}")
if (
isinstance(q, tuple)
and len(q) == 2
and isinstance(q[0], (float, int))
and isinstance(q[1], (float, int))
):
q_lower, q_upper = sorted(q)
xx = np.linspace(mean - rsd * sd, mean + rsd * sd, 200)
yy = stats.norm.pdf(xx, mean, sd)
fig = plt.figure(figsize=figsize, **kwargs)
ax = fig.gca()
ax.plot(xx, yy)
ax.set_xlabel(x_label)
ax.set_ylabel(y_label)
x = np.linspace(q_lower, q_upper, 200)
y = stats.norm.pdf(x, mean, sd)
# fmt: off
filled, *_ = ax.fill( # Fill returns a list of polygons, but we're only making one
np.concatenate([[q_lower], x, [q_upper]]),
np.concatenate([[0], y, [0]]),
color,
)
# fmt: on
if add_prob:
height = max(y) / 4
rv = stats.norm(mean, sd)
prob: float = rv.cdf(q_upper) - rv.cdf(q_lower)
ax.text((sum(q) / 2), height, f"{prob:.3f}", ha="center")
if add_q:
ax.set_xticks(
[q_lower, q_upper],
labels=[
str(round(q_lower, 4)),
str(round(q_upper, 4)),
],
minor=True,
color=color,
y=-0.05,
)
if q_lower in ax.get_xticks():
ax.get_xticklabels()[
np.where(ax.get_xticks() == q_lower)[0][0]
].set_color(color)
if q_upper in ax.get_xticks():
ax.get_xticklabels()[
np.where(ax.get_xticks() == q_upper)[0][0]
].set_color(color)

elif isinstance(q, (float, int)):
if not isinstance(lower_tail, bool):
raise TypeError(
f"lower_tail must be a bool, not a {lower_tail.__class__.__name__!r}"
)

xx = np.linspace(mean - rsd * sd, mean + rsd * sd, 200)
yy = stats.norm.pdf(xx, mean, sd)
fig = plt.figure(figsize=figsize, **kwargs)
ax = fig.gca()
ax.plot(xx, yy)
ax.set_xlabel(x_label)
ax.set_ylabel(y_label)

if lower_tail is True:
x = np.linspace(xx[0], q, 100)
y = stats.norm.pdf(x, mean, sd)
# fmt: off
filled, *_ = ax.fill( # Fill returns a list of polygons, but we're only making one
np.concatenate([[xx[0]], x, [q]]),
np.concatenate([[0], y, [0]]),
color,
)
# fmt: on
if add_prob:
height: float = stats.norm.pdf(q, mean, sd) / 4 # type: ignore
prob: float = stats.norm.cdf(q, mean, sd) # type: ignore
ax.text((q - 0.5 * sd), height, f"{prob:.3f}", ha="center")
else:
x = np.linspace(q, xx[-1], 100)
y = stats.norm.pdf(x, mean, sd)
# fmt: off
filled, *_ = ax.fill( # Fill returns a list of polygons, but we're only making one
np.concatenate([[q], x, [xx[-1]]]),
np.concatenate([[0], y, [0]]),
color,
)
# fmt: on
if add_prob:
height: float = stats.norm.pdf(q, mean, sd) / 4 # type: ignore
prob: float = stats.norm.sf(q, mean, sd) # type: ignore
ax.text((q + 0.5 * sd), height, f"{prob:.3f}", ha="center")

if add_q:
if q in ax.get_xticks():
ax.get_xticklabels()[np.where(ax.get_xticks() == q)[0][0]].set_color(
color
)
else:
ax.set_xticks(
[q],
labels=[
str(round(q, 4)),
],
minor=True,
color=color,
y=-0.05,
)

else:
error_base = "q must be a tuple of two numbers, or a single number"
if isinstance(q, tuple):
if len(q) != 2:
raise ValueError(f"{error_base}, not a {len(q)}-tuple")
raise TypeError(
f"{error_base}, not a 2-tuple containing a {q[0].__class__.__name__!r} and a {q[1].__class__.__name__!r}"
)
else:
raise TypeError(f"{error_base}, not a {q.__class__.__name__!r}")

if add_legend:
ax.set_title(legend_text or f"μ = {mean}, σ = {sd}")

return fig
2 changes: 1 addition & 1 deletion tests/test_problem_bank_helpers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from src.problem_bank_helpers import __version__

def test_version():
assert __version__ == '0.2.2'
assert __version__ == '0.2.5'

0 comments on commit 9b55dad

Please sign in to comment.