{
"cells": [
{
"cell_type": "markdown",
"id": "e9776d12",
"metadata": {},
"source": [
"# Working with Programs\n",
"\n",
"In Brush, a *Program* is an executable data structure. \n",
"You may think of it as a *model* or a *function* mapping feature inputs to data labels. \n",
"We call them programs because that's what they are: executable data structures, \n",
"and that is what they are called in the genetic algorithm literature, to distinguish them from optimizing bits or strings. \n",
"\n",
"The Brush Program class operates similarly to a [sklearn](scikit-learn.org) estimator: it has `fit` and `predict` methods that are called in during training or inference, respectively. \n",
"\n",
"\n",
"## Types of Programs \n",
"\n",
"There are four fundamental \"types\" of Brush programs:\n",
"\n",
"- **Regressors**: map inputs to a continous endpoint \n",
"- **Binary Classifiers**: map inputs to a binary endpoint, as well as a continuous value in $[0, 1]$ \n",
"- **Multi-class Classifiers**: map inputs to a category\n",
" - Under development\n",
"- **Representors**: map inputs to a lower dimensional space. \n",
" - Under development\n",
"\n",
"## Representation \n",
"\n",
"Internally, the programs are represented as syntax trees. \n",
"We use the [tree.hh tree class](https://github.com/kpeeters/tree.hh) which gives trees an STL-like feel. \n",
"\n",
"\n",
"\n",
"## Generation\n",
"\n",
"We generate random programs using Sean Luke's PTC2 algorithm. \n",
"\n",
"\n",
"## Evaluation\n",
"\n",
"TODO\n",
"\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "8c258669",
"metadata": {},
"source": [
"## Visualizing Programs\n",
"\n",
"Programs in Brush are symbolic tree structures, and can be viewed in a few ways: \n",
"\n",
"\n",
"1. As a string using `get_model()`\n",
"2. As a string-like tree using `get_model(\"tree\")`\n",
"2. As a graph using `graphviz` and `get_model(\"dot\")`. \n",
"\n",
"Let's look at a regresion example."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "102e3fcb",
"metadata": {},
"outputs": [],
"source": [
"import pandas as pd\n",
"from pybrush import BrushRegressor\n",
"\n",
"# load data\n",
"df = pd.read_csv('../examples/datasets/d_enc.csv')\n",
"X = df.drop(columns='label')\n",
"y = df['label']"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "ac39c9ca",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Completed 100% [====================]\n",
"score: 0.9441623847546605\n"
]
}
],
"source": [
"# import and make a regressor\n",
"est = BrushRegressor(\n",
" functions=['SplitBest','Mul','Add','Cos','Exp','Sin'],\n",
" max_depth=5,\n",
" verbosity=1 # set verbosity==1 to see a progress bar\n",
")\n",
"\n",
"# use like you would a sklearn regressor\n",
"est.fit(X,y)\n",
"y_pred = est.predict(X)\n",
"print('score:', est.score(X,y))"
]
},
{
"cell_type": "markdown",
"id": "5bbd24cd",
"metadata": {},
"source": [
"You can see the fitness of the final individual by accessing the `fitness` attribute. Each fitness value corresponds to the objective of same index defined earlier for the `BrushRegressor` class. By default, it will try to minimize `\"scorer\"` and `\"size\"`, where `scorer` is a string with a loss function name, set with `scorer` parameter."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "166415c2",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Fitness(4.757904 74.000000 )\n",
"['scorer', 'linear_complexity']\n"
]
}
],
"source": [
"print(est.best_estimator_.fitness)\n",
"print(est.objectives)"
]
},
{
"cell_type": "markdown",
"id": "38b6364e",
"metadata": {},
"source": [
"A `fitness` in Brush is actually more than a tuple. It is a class that has all boolean comparison operators overloaded to allow an ease of use when prototyping with Brush.\n",
"\n",
"It also infers the weight of each objective to automatically handle minimization or maximization objetives.\n",
"\n",
"To see the weights, you can try:"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "13d0ac5f",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[-1.0, -1.0]"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"est.best_estimator_.fitness.weights"
]
},
{
"cell_type": "markdown",
"id": "2fc9fe80",
"metadata": {},
"source": [
"Other information of the best estimator can also be accessed through its fitness attribute:"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "de5255e7",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"28\n",
"134628\n",
"5\n"
]
}
],
"source": [
"print(est.best_estimator_.fitness.size)\n",
"print(est.best_estimator_.fitness.complexity)\n",
"print(est.best_estimator_.fitness.depth)"
]
},
{
"cell_type": "markdown",
"id": "fe594691",
"metadata": {},
"source": [
"## Serialization \n",
"\n",
"Brush let's you serialize the entire individual, or just the program or fitness it wraps. It uses JSON to serialize the objects, and this is implemented with the get and set states of an object:"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "b01ab1fa",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"fitness {'complexity': 134628, 'crowding_dist': 0.0984368622303009, 'dcounter': 0, 'depth': 5, 'dominated': [0, 15], 'linear_complexity': 74, 'loss': 5.118783950805664, 'loss_v': 4.757904052734375, 'prev_complexity': 134628, 'prev_depth': 5, 'prev_linear_complexity': 74, 'prev_loss': 5.118783950805664, 'prev_loss_v': 4.757904052734375, 'prev_size': 28, 'rank': 1, 'size': 28, 'values': [4.757904052734375, 74.0], 'weights': [-1.0, -1.0], 'wvalues': [-4.757904052734375, -74.0]}\n",
"id 227\n",
"is_fitted_ False\n",
"objectives ['mse', 'linear_complexity']\n",
"parent_id [245]\n",
"program {'Tree': [{'W': 0.5537276268005371, 'arg_types': ['ArrayF', 'ArrayF'], 'center_op': True, 'feature': '', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': False, 'name': 'Add', 'node_type': 'Add', 'prob_change': 1.0, 'ret_type': 'ArrayF', 'sig_dual_hash': 14679000877885575597, 'sig_hash': 14400282083458657357}, {'W': 11.378684043884277, 'arg_types': ['ArrayF'], 'center_op': True, 'feature': '', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': True, 'name': 'Exp', 'node_type': 'Exp', 'prob_change': 1.0, 'ret_type': 'ArrayF', 'sig_dual_hash': 13056393536346412951, 'sig_hash': 14128685871577087634}, {'W': 0.9108647704124451, 'arg_types': [], 'center_op': True, 'feature': 'x6', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': False, 'name': 'Terminal', 'node_type': 'Terminal', 'prob_change': 0.20750471949577332, 'ret_type': 'ArrayF', 'sig_dual_hash': 7018942542468397869, 'sig_hash': 14162902253047951597}, {'W': 0.7599999904632568, 'arg_types': ['ArrayF', 'ArrayF'], 'center_op': True, 'feature': 'x0', 'feature_type': 'ArrayI', 'fixed': False, 'is_weighted': True, 'name': 'SplitBest', 'node_type': 'SplitBest', 'prob_change': 1.0, 'ret_type': 'ArrayF', 'sig_dual_hash': 14679000877885575597, 'sig_hash': 14400282083458657357}, {'W': 0.8199999928474426, 'arg_types': ['ArrayF', 'ArrayF'], 'center_op': True, 'feature': 'x0', 'feature_type': 'ArrayI', 'fixed': False, 'is_weighted': True, 'name': 'SplitBest', 'node_type': 'SplitBest', 'prob_change': 1.0, 'ret_type': 'ArrayF', 'sig_dual_hash': 14679000877885575597, 'sig_hash': 14400282083458657357}, {'W': 15.890183448791504, 'arg_types': [], 'center_op': True, 'feature': 'constF', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': True, 'name': 'Constant', 'node_type': 'Constant', 'prob_change': 0.6167147755622864, 'ret_type': 'ArrayF', 'sig_dual_hash': 7018942542468397869, 'sig_hash': 14162902253047951597}, {'W': 0.17655502259731293, 'arg_types': [], 'center_op': True, 'feature': 'x3', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': True, 'name': 'Terminal', 'node_type': 'Terminal', 'prob_change': 0.8625447154045105, 'ret_type': 'ArrayF', 'sig_dual_hash': 7018942542468397869, 'sig_hash': 14162902253047951597}, {'W': 0.14056099951267242, 'arg_types': ['ArrayF'], 'center_op': True, 'feature': '', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': False, 'name': 'Exp', 'node_type': 'Exp', 'prob_change': 1.0, 'ret_type': 'ArrayF', 'sig_dual_hash': 13056393536346412951, 'sig_hash': 14128685871577087634}, {'W': 1.7882635593414307, 'arg_types': ['ArrayF'], 'center_op': True, 'feature': '', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': True, 'name': 'Sin', 'node_type': 'Sin', 'prob_change': 1.0, 'ret_type': 'ArrayF', 'sig_dual_hash': 13056393536346412951, 'sig_hash': 14128685871577087634}, {'W': 0.8196039795875549, 'arg_types': [], 'center_op': True, 'feature': 'x1', 'feature_type': 'ArrayF', 'fixed': False, 'is_weighted': True, 'name': 'Terminal', 'node_type': 'Terminal', 'prob_change': 0.6729995608329773, 'ret_type': 'ArrayF', 'sig_dual_hash': 7018942542468397869, 'sig_hash': 14162902253047951597}], 'is_fitted_': True}\n",
"variation point\n"
]
}
],
"source": [
"estimator_dict = est.best_estimator_.__getstate__()\n",
"\n",
"for k, v in estimator_dict.items():\n",
" print(k, v)"
]
},
{
"cell_type": "markdown",
"id": "6bcb071b",
"metadata": {},
"source": [
"With serialization, you can use pickle to save and load just programs or even the entire individual."
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "b4537631",
"metadata": {},
"outputs": [],
"source": [
"import pickle\n",
"import os, tempfile\n",
"\n",
"individual_file = os.path.join(tempfile.mkdtemp(), 'individual.json')\n",
"with open(individual_file, \"wb\") as f:\n",
" pickle.dump(est.best_estimator_, f)\n",
"\n",
"program_file = os.path.join(tempfile.mkdtemp(), 'program.json')\n",
"with open(program_file, \"wb\") as f:\n",
" pickle.dump(est.best_estimator_.program, f)"
]
},
{
"cell_type": "markdown",
"id": "fff5693d",
"metadata": {},
"source": [
"Then we can load it later with:"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "ee7a20c6",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Add(11.38*Exp(x6),If(x0>=0.76,If(x0>=0.82,15.89,0.18*x3),Exp(1.79*Sin(0.82*x1))))\n"
]
}
],
"source": [
"with open(individual_file, \"rb\") as f:\n",
" loaded_estimator = pickle.load(f)\n",
" print(loaded_estimator.get_model())"
]
},
{
"cell_type": "markdown",
"id": "a355d8f3",
"metadata": {},
"source": [
"### String\n",
"\n",
"Now that we have trained a model, `est.best_estimator_` contains our symbolic model. \n",
"We can view it as a string:"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "316964d5",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Add(11.38*Exp(x6),If(x0>=0.76,If(x0>=0.82,15.89,0.18*x3),Exp(1.79*Sin(0.82*x1))))\n"
]
}
],
"source": [
"print(est.best_estimator_.get_model())"
]
},
{
"cell_type": "markdown",
"id": "e7d578bb",
"metadata": {},
"source": [
"### Quick Little Tree\n",
"\n",
"Or, we can view it as a compact tree:"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "dad68d01",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Add\n",
"|- 11.38*Exp\n",
"| |- x6\n",
"|- If(x0>=0.76)\n",
"| |- If(x0>=0.82)\n",
"| | |- 15.89\n",
"| | |- 0.18*x3\n",
"| |- Exp\n",
"| | |- 1.79*Sin\n",
"| | | |- 0.82*x1\n"
]
}
],
"source": [
"print(est.best_estimator_.get_model(\"tree\"))"
]
},
{
"cell_type": "markdown",
"id": "90068143",
"metadata": {},
"source": [
"### GraphViz\n",
"\n",
"If we are feeling fancy 🎩, we can also view it as a graph in dot format. \n",
"Let's import graphviz and make a nicer plot."
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "3ef1a735",
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
""
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import graphviz\n",
"\n",
"model = est.best_estimator_.get_model(\"dot\")\n",
"graphviz.Source(model)"
]
},
{
"cell_type": "markdown",
"id": "a2f93509",
"metadata": {},
"source": [
"The `model` variable is now a little program in the [dot language](https://graphviz.org/docs/layouts/dot/), which we can inspect directly. "
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "1f7e725e",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"digraph G {\n",
"\"141fd3450\" [label=\"Add\"];\n",
"\"141fd3450\" -> \"141fd2090\" [label=\"11.38\"];\n",
"\"141fd3450\" -> \"141fcbe00\" [label=\"\"];\n",
"\"141fd2090\" [label=\"Exp\"];\n",
"\"141fd2090\" -> \"x6\" [label=\"\"];\n",
"\"x6\" [label=\"x6\"];\n",
"\"141fcbe00\" [label=\"x0>=0.76?\"];\n",
"\"141fcbe00\" -> \"141fe2370\" [headlabel=\"\",taillabel=\"Y\"];\n",
"\"141fcbe00\" -> \"141ff19f0\" [headlabel=\"\",taillabel=\"N\"];\n",
"\"141fe2370\" [label=\"x0>=0.82?\"];\n",
"\"141fe2370\" -> \"141fe2420\" [headlabel=\"\",taillabel=\"Y\"];\n",
"\"141fe2370\" -> \"x3\" [headlabel=\"0.18\",taillabel=\"N\"];\n",
"\"141fe2420\" [label=\"15.89\"];\n",
"\"x3\" [label=\"x3\"];\n",
"\"141ff19f0\" [label=\"Exp\"];\n",
"\"141ff19f0\" -> \"141fd89e0\" [label=\"1.79\"];\n",
"\"141fd89e0\" [label=\"Sin\"];\n",
"\"141fd89e0\" -> \"x1\" [label=\"0.82\"];\n",
"\"x1\" [label=\"x1\"];\n",
"}\n",
"\n"
]
}
],
"source": [
"print(model)"
]
},
{
"cell_type": "markdown",
"id": "b9c0154a",
"metadata": {},
"source": [
"### Tweaking Graphs\n",
"\n",
"The [dot manual](https://graphviz.org/docs/layouts/dot/) has lots of options for tweaking the graphs. \n",
"You can do this by manually editing `model`, but brush also provides a function, `get_dot_model()`, to which you can pass additional arguments to dot. \n",
"\n",
"For example, let's view the graph from Left-to-Right: "
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "f35b1e05",
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
""
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"model = est.best_estimator_.get_dot_model(\"rankdir=LR;\")\n",
"graphviz.Source(model)"
]
},
{
"cell_type": "markdown",
"id": "512434e7",
"metadata": {},
"source": [
"### A classification example"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "4ca564f3",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Completed 100% [====================]\n",
"Best model: Logistic\n",
"|- -0.23+Sum\n",
"| |- Sin\n",
"| | |- If(AIDS>=16068.00)\n",
"| | | |- 70.58\n",
"| | | |- If(Total>=1601948.00)\n",
"| | | | |- -1.43\n",
"| | | | |- If(AIDS>=258.00)\n",
"| | | | | |- 70.58\n",
"| | | | | |- Total\n"
]
}
],
"source": [
"from pybrush import BrushClassifier\n",
"from sklearn.preprocessing import StandardScaler\n",
"\n",
"# load data\n",
"df = pd.read_csv('../examples/datasets/d_analcatdata_aids.csv')\n",
"X = df.drop(columns='target')\n",
"y = df['target']\n",
"\n",
"est = BrushClassifier(\n",
" functions=['SplitBest', 'And', 'Sin', 'Cos', 'Exp'],\n",
" max_gens=500,\n",
" max_size=50,\n",
" max_depth=15,\n",
" objectives=[\"scorer\", \"linear_complexity\"], \n",
" scorer=\"log\",\n",
" pop_size=100,\n",
" bandit='dynamic_thompson',\n",
" verbosity=1\n",
")\n",
"\n",
"est.fit(X,y)\n",
"\n",
"print(\"Best model:\", est.best_estimator_.get_model(\"tree\"))"
]
},
{
"cell_type": "markdown",
"id": "b1e8c636",
"metadata": {},
"source": [
"Notice that classification problems have a fixed logistic root on their programs. When printing the dot model, Brush will highlight fixed nodes using a light coral color."
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "05a76db6",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== Search space ===\n",
"terminal_map: {\"ArrayB\": [\"1.00\"], \"ArrayI\": [\"Age\", \"Race\", \"1.00\"], \"ArrayF\": [\"AIDS\", \"Total\", \"1.00\"]}\n",
"terminal_weights: {\"ArrayB\": [0.27714446], \"ArrayI\": [0.5782708, 0.85288507, 0.491237], \"ArrayF\": [0.47180814, 0.6575688, 0.07907883]}\n",
"SplitBest node_map[ArrayI][[\"ArrayI\", \"ArrayI\"]][SplitBest] = 1.00*SplitBest, weight = 0.58001214\n",
"OffsetSum node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\", \"ArrayF\"]][OffsetSum] = 0.00+Sum, weight = 0\n",
"Logistic node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\", \"ArrayF\"]][Logistic] = 1.00*Logistic, weight = 0\n",
"Sin node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\", \"ArrayF\"]][Sin] = 1.00*Sin, weight = 0.9063738\n",
"Cos node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\", \"ArrayF\"]][Cos] = 1.00*Cos, weight = 0.60503954\n",
"Exp node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\", \"ArrayF\"]][Exp] = 1.00*Exp, weight = 0.620445\n",
"OffsetSum node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\"]][OffsetSum] = 0.00+Sum, weight = 0\n",
"Logistic node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\"]][Logistic] = 1.00*Logistic, weight = 0\n",
"Sin node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\"]][Sin] = 1.00*Sin, weight = 0.7408498\n",
"Cos node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\"]][Cos] = 1.00*Cos, weight = 0.5123497\n",
"Exp node_map[MatrixF][[\"ArrayF\", \"ArrayF\", \"ArrayF\"]][Exp] = 1.00*Exp, weight = 0.59719974\n",
"OffsetSum node_map[MatrixF][[\"ArrayF\", \"ArrayF\"]][OffsetSum] = 0.00+Sum, weight = 0\n",
"Logistic node_map[MatrixF][[\"ArrayF\", \"ArrayF\"]][Logistic] = 1.00*Logistic, weight = 0\n",
"Sin node_map[MatrixF][[\"ArrayF\", \"ArrayF\"]][Sin] = 1.00*Sin, weight = 0.58827883\n",
"Cos node_map[MatrixF][[\"ArrayF\", \"ArrayF\"]][Cos] = 1.00*Cos, weight = 0.04645303\n",
"Exp node_map[MatrixF][[\"ArrayF\", \"ArrayF\"]][Exp] = 1.00*Exp, weight = 0.5686126\n",
"SplitBest node_map[ArrayF][[\"ArrayF\", \"ArrayF\"]][SplitBest] = 1.00*SplitBest, weight = 0.35056993\n",
"OffsetSum node_map[ArrayF][[\"ArrayF\"]][OffsetSum] = 0.00+Sum, weight = 0\n",
"Logistic node_map[ArrayF][[\"ArrayF\"]][Logistic] = 1.00*Logistic, weight = 0\n",
"Sin node_map[ArrayF][[\"ArrayF\"]][Sin] = 1.00*Sin, weight = 0.90100724\n",
"Cos node_map[ArrayF][[\"ArrayF\"]][Cos] = 1.00*Cos, weight = 0.17278638\n",
"Exp node_map[ArrayF][[\"ArrayF\"]][Exp] = 1.00*Exp, weight = 0.43059298\n",
"\n",
"Best model: Logistic(Sum(-0.23,Sin(If(AIDS>=16068.00,70.58,If(Total>=1601948.00,-1.43,If(AIDS>=258.00,70.58,Total))))))\n",
"score: 0.82\n",
"Best model: Logistic\n",
"|- -0.23+Sum\n",
"| |- Sin\n",
"| | |- If(AIDS>=16068.00)\n",
"| | | |- 70.58\n",
"| | | |- If(Total>=1601948.00)\n",
"| | | | |- -1.43\n",
"| | | | |- If(AIDS>=258.00)\n",
"| | | | | |- 70.58\n",
"| | | | | |- Total\n"
]
},
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
""
]
},
"execution_count": 15,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"est.engine_.search_space.print()\n",
"\n",
"print(\"Best model:\", est.best_estimator_.get_model())\n",
"print('score:', est.score(X,y))\n",
"\n",
"print(\"Best model:\", est.best_estimator_.get_model(\"tree\"))\n",
"\n",
"model = est.best_estimator_.get_dot_model()\n",
"graphviz.Source(model)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "brush",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}