{"id":15469274,"url":"https://github.com/maxhalford/sorobn","last_synced_at":"2025-04-07T19:16:45.535Z","repository":{"id":37012501,"uuid":"261469835","full_name":"MaxHalford/sorobn","owner":"MaxHalford","description":"🧮 Bayesian networks in Python","archived":false,"fork":false,"pushed_at":"2024-04-03T15:22:20.000Z","size":562,"stargazers_count":253,"open_issues_count":5,"forks_count":35,"subscribers_count":7,"default_branch":"master","last_synced_at":"2025-03-31T16:17:42.570Z","etag":null,"topics":["bayesian-network"],"latest_commit_sha":null,"homepage":"https://sorobn.streamlit.app","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/MaxHalford.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":"CITATION.cff","codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2020-05-05T13:00:36.000Z","updated_at":"2025-03-15T01:01:09.000Z","dependencies_parsed_at":"2024-10-23T01:33:18.951Z","dependency_job_id":null,"html_url":"https://github.com/MaxHalford/sorobn","commit_stats":{"total_commits":118,"total_committers":2,"mean_commits":59.0,"dds":"0.016949152542372836","last_synced_commit":"5e141935c6e0507a3c2c5d878f8995855c960b65"},"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/MaxHalford%2Fsorobn","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/MaxHalford%2Fsorobn/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/MaxHalford%2Fsorobn/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/MaxHalford%2Fsorobn/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/MaxHalford","download_url":"https://codeload.github.com/MaxHalford/sorobn/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247713258,"owners_count":20983683,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bayesian-network"],"created_at":"2024-10-02T01:58:35.161Z","updated_at":"2025-04-07T19:16:45.496Z","avatar_url":"https://github.com/MaxHalford.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n    \u003ch1\u003esorobn — Bayesian networks in Python\u003c/h1\u003e\n    \u003cdiv\u003e\n        \u003ca href=\"https://github.com/MaxHalford/sorobn/actions/workflows/unit-tests.yml\"\u003e\u003cimg src=\"https://github.com/MaxHalford/sorobn/actions/workflows/unit-tests.yml/badge.svg\" /\u003e\u003c/a\u003e\n    \u003c/div\u003e\n\u003c/div\u003e\n\u003c/br\u003e\n\n\u003cimg style=\"padding-bottom: 20px\" src=\"https://user-images.githubusercontent.com/8095957/225851341-31acd01b-54ad-429d-9d14-ee367edfb76d.png\" width=\"33%\" alt=\"DALL·E 2023-03-17 09 21 56 - An oil painting by Matisse of a Bayesian network  Each node in the network is an abacus with red balls and a wooden frame\" align=\"right\" /\u003e\n\nThis is an unambitious Python library for working with [Bayesian networks](https://www.wikiwand.com/en/Bayesian_network). For serious usage, you should probably be using a more established project, such as [pomegranate](https://pomegranate.readthedocs.io/en/latest/), [pgmpy](http://pgmpy.org/), [bnlearn](https://erdogant.github.io/bnlearn/pages/html/index.html) (which is built on the latter), or even [PyMC](https://docs.pymc.io/). There's also the well-documented [bnlearn](https://www.bnlearn.com/) package in R. Hey, you could even go medieval and use something like [Netica](https://www.norsys.com/) — I'm just jesting, they actually have a [nice tutorial on Bayesian networks](https://www.norsys.com/tutorials/netica/secA/tut_A1.htm). By the way, if you're not familiar with Bayesian networks, then I highly recommend Patrick Winston's MIT courses on probabilistic inference ([part 1](https://www.youtube.com/watch?v=A6Ud6oUCRak), [part 2](https://www.youtube.com/watch?v=EC6bf8JCpDQ)).\n\nThe main goal of this project is to be used for educational purposes. As such, more emphasis is put on tidyness and conciseness than on performance. I find libraries such as [pomegranate](https://pomegranate.readthedocs.io/en/latest/) are wonderful. But, they literally contain several thousand lines of non-obvious code, at the detriment of simplicity and ease of comprehension. I've also put some effort into designing a slick API that makes full use of [pandas](https://pandas.pydata.org/). Although performance is not the main focus of this library, it is reasonably efficient and should be able to satisfy most use cases in a timely manner.\n\n## Table of contents\n\n- [Table of contents](#table-of-contents)\n- [Installation](#installation)\n- [Usage](#usage)\n  - [✍️ Manual structures](#️-manual-structures)\n  - [🎲 Random sampling](#-random-sampling)\n  - [🔮 Probabilistic inference](#-probabilistic-inference)\n  - [❓ Missing value imputation](#-missing-value-imputation)\n  - [🤷 Likelihood estimation](#-likelihood-estimation)\n  - [🧮 Parameter estimation](#-parameter-estimation)\n  - [🧱 Structure learning](#-structure-learning)\n    - [🌳 Chow-Liu trees](#-chow-liu-trees)\n  - [👀 Visualization](#-visualization)\n  - [👁️ Graphical user interface](#️-graphical-user-interface)\n  - [🔢 Support for continuous variables](#-support-for-continuous-variables)\n- [Toy networks](#toy-networks)\n- [Development](#development)\n- [License](#license)\n\n## Installation\n\nYou should be able to install and use this library with any Python version above 3.9:\n\n```sh\npip install sorobn\n```\n\nNote that under the hood, `sorobn` uses [`vose`](https://github.com/MaxHalford/vose) for random sampling, which is written in Cython.\n\n## Usage\n\n### ✍️ Manual structures\n\nThe central construct in `sorobn` is the `BayesNet` class. A Bayesian network's structure can be manually defined by instantiating a `BayesNet`. As an example, let's use [Judea Pearl's famous alarm network](https://books.google.fr/books?id=vFk7DwAAQBAJ\u0026pg=PT40\u0026lpg=PT40\u0026dq=judea+pearl+alarm+network\u0026source=bl\u0026ots=Sa24Dczalo\u0026sig=ACfU3U1yGe85VxGkygAx5G-X6UwYodHpTg\u0026hl=en\u0026sa=X\u0026ved=2ahUKEwjVxJOQvbDpAhUSx4UKHTHPBkwQ6AEwAHoECAoQAQ#v=onepage\u0026q=judea%20pearl%20alarm%20network\u0026f=false):\n\n```python\n\u003e\u003e\u003e import sorobn as hh\n\n\u003e\u003e\u003e bn = hh.BayesNet(\n...     ('Burglary', 'Alarm'),\n...     ('Earthquake', 'Alarm'),\n...     ('Alarm', 'John calls'),\n...     ('Alarm', 'Mary calls'),\n...     seed=42,\n... )\n\n```\n\nYou may also use the following notation, which is slightly more terse:\n\n```python\n\u003e\u003e\u003e import sorobn as hh\n\n\u003e\u003e\u003e bn = hh.BayesNet(\n...     (['Burglary', 'Earthquake'], 'Alarm'),\n...     ('Alarm', ['John calls', 'Mary calls']),\n...     seed=42\n... )\n\n```\n\nIn Judea Pearl's example, the [conditional probability tables](https://www.wikiwand.com/en/Conditional_probability_table) are given. Therefore, we can define them manually by setting the values of the `P` attribute:\n\n```python\n\u003e\u003e\u003e import pandas as pd\n\n# P(Burglary)\n\u003e\u003e\u003e bn.P['Burglary'] = pd.Series({False: .999, True: .001})\n\n# P(Earthquake)\n\u003e\u003e\u003e bn.P['Earthquake'] = pd.Series({False: .998, True: .002})\n\n# P(Alarm | Burglary, Earthquake)\n\u003e\u003e\u003e bn.P['Alarm'] = pd.Series({\n...     (True, True, True): .95,\n...     (True, True, False): .05,\n...\n...     (True, False, True): .94,\n...     (True, False, False): .06,\n...\n...     (False, True, True): .29,\n...     (False, True, False): .71,\n...\n...     (False, False, True): .001,\n...     (False, False, False): .999\n... })\n\n# P(John calls | Alarm)\n\u003e\u003e\u003e bn.P['John calls'] = pd.Series({\n...     (True, True): .9,\n...     (True, False): .1,\n...     (False, True): .05,\n...     (False, False): .95\n... })\n\n# P(Mary calls | Alarm)\n\u003e\u003e\u003e bn.P['Mary calls'] = pd.Series({\n...     (True, True): .7,\n...     (True, False): .3,\n...     (False, True): .01,\n...     (False, False): .99\n... })\n\n```\n\nThe `prepare` method has to be called whenever the structure and/or the P are manually specified. This will do some house-keeping and make sure everything is sound. It is not compulsory but highly recommended, just like brushing your teeth.\n\n```python\n\u003e\u003e\u003e bn.prepare()\n\n```\n\nNote that you are allowed to specify variables that have no dependencies with any other variable:\n\n```python\n\u003e\u003e\u003e _ = hh.BayesNet(\n...     ('Cloud', 'Rain'),\n...     (['Rain', 'Cold'], 'Snow'),\n...     'Wind speed'  # has no dependencies\n... )\n\n```\n\n### 🎲 Random sampling\n\nYou can use a Bayesian network to generate random samples. The samples will follow the distribution induced by the network's structure and its conditional probability tables.\n\n```python\n\u003e\u003e\u003e from pprint import pprint\n\n\u003e\u003e\u003e pprint(bn.sample())\n{'Alarm': False,\n 'Burglary': False,\n 'Earthquake': False,\n 'John calls': False,\n 'Mary calls': False}\n\n\u003e\u003e\u003e bn.sample(5)  # doctest: +SKIP\n    Alarm  Burglary  Earthquake  John calls  Mary calls\n0  False     False       False       False       False\n1  False     False       False       False       False\n2  False     False       False       False       False\n3  False     False       False       False       False\n4  False     False       False        True       False\n\n```\n\nYou can also specify starting values for a subset of the variables.\n\n```python\n\u003e\u003e\u003e pprint(bn.sample(init={'Alarm': True, 'Burglary': True}))\n{'Alarm': True,\n 'Burglary': True,\n 'Earthquake': False,\n 'John calls': True,\n 'Mary calls': False}\n\n```\n\n\u003c!-- There are different sampling methods which you can choose from.\n\n```python\n\u003e pprint(bn.sample(method='backward'))\n{'Alarm': False,\n 'Burglary': False,\n 'Earthquake': False,\n 'John calls': False,\n 'Mary calls': False}\n\n\u003e pprint(bn.sample(init={'Earthquake': True}, method='backward'))\n{'Alarm': True,\n 'Burglary': False,\n 'Earthquake': True,\n 'John calls': True,\n 'Mary calls': False}\n\n``` --\u003e\n\nThe supported inference methods are:\n\n- `forward` for [forward sampling](https://ermongroup.github.io/cs228-notes/inference/sampling/#forward-sampling).\n\u003c!--- `backward` for [backward sampling](https://arxiv.org/ftp/arxiv/papers/1302/1302.6807.pdf).--\u003e\n\nNote that randomness is controlled via the `seed` parameter, when `BayesNet` is initialized.\n\n### 🔮 Probabilistic inference\n\nA Bayesian network is a [generative model](https://www.wikiwand.com/en/Generative_model). Therefore, it can be used for many purposes. For instance, it can answer probabilistic queries, such as:\n\n\u003e What is the likelihood of there being a burglary if both John and Mary call?\n\nThis question can be answered by using the `query` method, which returns the probability distribution for the possible outcomes. Said otherwise, the `query` method can be used to look at a query variable's distribution conditioned on a given event. This can be denoted as `P(query | event)`.\n\n\n```python\n\u003e\u003e\u003e bn.query('Burglary', event={'Mary calls': True, 'John calls': True})\nBurglary\nFalse    0.715828\nTrue     0.284172\nName: P(Burglary), dtype: float64\n\n```\n\nWe can also answer questions that involve multiple query variables, for instance:\n\n\u003e What are the chances that John and Mary call if an earthquake happens?\n\n```python\n\u003e\u003e\u003e bn.query('John calls', 'Mary calls', event={'Earthquake': True})\nJohn calls  Mary calls\nFalse       False         0.675854\n            True          0.027085\nTrue        False         0.113591\n            True          0.183470\nName: P(John calls, Mary calls), dtype: float64\n\n```\n\nBy default, the answer is found via an exact inference procedure. For small networks this isn't very expensive to perform. However, for larger networks, you might want to prefer using [approximate inference](https://www.wikiwand.com/en/Approximate_inference). The latter is a class of methods that randomly sample the network and return an estimate of the answer. The quality of the estimate increases with the number of iterations that are performed. For instance, you can use [Gibbs sampling](https://www.wikiwand.com/en/Gibbs_sampling):\n\n```python\n\u003e\u003e\u003e bn.query(\n...     'Burglary',\n...     event={'Mary calls': True, 'John calls': True},\n...     algorithm='gibbs',\n...     n_iterations=1000\n... )  # doctest: +SKIP\nBurglary\nFalse    0.706\nTrue     0.294\nName: P(Burglary), dtype: float64\n\n```\n\nThe supported inference methods are:\n\n- `exact` for [variable elimination](https://www.wikiwand.com/en/Variable_elimination).\n- `gibbs` for [Gibbs sampling](https://www.wikiwand.com/en/Gibbs_sampling).\n- `likelihood` for [likelihood weighting](https://artint.info/2e/html/ArtInt2e.Ch8.S6.SS4.html).\n- `rejection` for [rejection sampling](https://www.wikiwand.com/en/Rejection_sampling).\n\nAs with random sampling, randomness is controlled during `BayesNet` initialization, via the `seed` parameter.\n\n### ❓ Missing value imputation\n\nA use case for probabilistic inference is to impute missing values. The `impute` method fills the missing values with the most likely replacements, given the present information. This is usually more accurate than simply replacing by the mean or the most common value. Additionally, such an approach can be much more efficient than [model-based iterative imputation](https://scikit-learn.org/stable/modules/generated/sklearn.impute.IterativeImputer.html#sklearn.impute.IterativeImputer).\n\n```python\n\u003e\u003e\u003e sample = {\n...     'Alarm': True,\n...     'Burglary': True,\n...     'Earthquake': False,\n...     'John calls': None,  # missing\n...     'Mary calls': None   # missing\n... }\n\n\u003e\u003e\u003e sample = bn.impute(sample)\n\u003e\u003e\u003e pprint(sample)\n{'Alarm': True,\n 'Burglary': True,\n 'Earthquake': False,\n 'John calls': True,\n 'Mary calls': True}\n\n```\n\nNote that the `impute` method can be seen as the equivalent of [`pomegranate`'s `predict` method](https://pomegranate.readthedocs.io/en/latest/BayesianNetwork.html#prediction).\n\n### 🤷 Likelihood estimation\n\nYou can estimate the likelihood of an event with the `predict_proba` method:\n\n```py\n\u003e\u003e\u003e event = {\n...     'Alarm': False,\n...     'Burglary': False,\n...     'Earthquake': False,\n...     'John calls': False,\n...     'Mary calls': False\n... }\n\n\u003e\u003e\u003e bn.predict_proba(event)\n0.936742...\n\n```\n\nIn other words, `predict_proba` computes `P(event)`, whereas the `query` method computes `P(query | event)`. You may also estimate the likelihood for a partial event. The probabilities for the unobserved variables will be summed out.\n\n```py\n\u003e\u003e\u003e event = {'Alarm': True, 'Burglary': False}\n\u003e\u003e\u003e bn.predict_proba(event)\n0.001576...\n\n```\n\nThis also works for an event with a single variable:\n\n```py\n\u003e\u003e\u003e event = {'Alarm': False}\n\u003e\u003e\u003e bn.predict_proba(event)\n0.997483...\n\n```\n\nNote that you can also pass a bunch of events to `predict_proba`, as so:\n\n```py\n\u003e\u003e\u003e events = pd.DataFrame([\n...     {'Alarm': False, 'Burglary': False, 'Earthquake': False,\n...      'John calls': False, 'Mary calls': False},\n...\n...     {'Alarm': False, 'Burglary': False, 'Earthquake': False,\n...      'John calls': True, 'Mary calls': False},\n...\n...     {'Alarm': True, 'Burglary': True, 'Earthquake': True,\n...      'John calls': True, 'Mary calls': True}\n... ])\n\n\u003e\u003e\u003e bn.predict_proba(events)\nAlarm  Burglary  Earthquake  John calls  Mary calls\nFalse  False     False       False       False         0.936743\n                             True        False         0.049302\nTrue   True      True        True        True          0.000001\nName: P(Alarm, Burglary, Earthquake, John calls, Mary calls), dtype: float64\n\n```\n\n### 🧮 Parameter estimation\n\nYou can determine the values of the P from a dataset. This is a straightforward procedure, as it only requires performing a [`groupby`](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.groupby.html) followed by a [`value_counts`](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.value_counts.html) for each CPT.\n\n```python\n\u003e\u003e\u003e samples = bn.sample(1000)\n\u003e\u003e\u003e bn = bn.fit(samples)\n\n```\n\nNote that in this case you do not have to call the `prepare` method because it is done for you implicitly.\n\nIf you want to update an already existing Bayesian networks with new observations, then you can use `partial_fit`:\n\n```python\n\u003e\u003e\u003e bn = bn.partial_fit(samples[:500])\n\u003e\u003e\u003e bn = bn.partial_fit(samples[500:])\n\n```\n\nThe same result will be obtained whether you use `fit` once or `partial_fit` multiple times in succession.\n\n### 🧱 Structure learning\n\n#### 🌳 Chow-Liu trees\n\nA Chow-Liu tree is a tree structure that represents a factorised distribution with maximal likelihood. It's essentially the best tree structure that can be found.\n\n```python\n\u003e\u003e\u003e samples = hh.examples.asia().sample(300)\n\u003e\u003e\u003e structure = hh.structure.chow_liu(samples)\n\u003e\u003e\u003e bn = hh.BayesNet(*structure)\n\n```\n\n### 👀 Visualization\n\nYou can use the `graphviz` method to obtain a [`graphviz.Digraph`](https://graphviz.readthedocs.io/en/stable/api.html#graphviz.Digraph) representation.\n\n```python\n\u003e\u003e\u003e bn = hh.examples.asia()\n\u003e\u003e\u003e dot = bn.graphviz()\n\u003e\u003e\u003e path = dot.render('asia', directory='figures', format='svg', cleanup=True)\n\n```\n\n\u003c/br\u003e\n\u003cdiv align=\"center\"\u003e\n    \u003cimg src=\"figures/asia.svg\"\u003e\n\u003c/div\u003e\n\u003c/br\u003e\n\nNote that the [`graphviz` library](https://graphviz.readthedocs.io/en/stable/) is not installed by default because it requires a platform dependent binary. Therefore, you have to [install it](https://graphviz.readthedocs.io/en/stable/#installation) by yourself.\n\n### 👁️ Graphical user interface\n\nA side-goal of this project is to provide a user interface to play around with a given user interface. Fortunately, we live in wonderful times where many powerful and opensource tools are available. At the moment, I have a preference for [`streamlit`](https://www.streamlit.io/).\n\nYou can install the GUI dependencies by running the following command:\n\n```sh\n$ pip install git+https://github.com/MaxHalford/sorobn --install-option=\"--extras-require=gui\"\n```\n\nYou can then launch a demo by running the `sorobn` command:\n\n```sh\n$ sorobn\n```\n\nThis will launch a `streamlit` interface where you can play around with the examples that `sorobn` provides. You can see a running instance of it in [this Streamlit app](https://sorobn.streamlit.app/).\n\nAn obvious next step would be to allow users to run this with their own Bayesian networks. Then again, using `streamlit` is so easy that you might as well do this yourself.\n\n### 🔢 Support for continuous variables\n\nBayesian networks that handle both discrete and continuous are said to be *hybrid*. There are two approaches to deal with continuous variables. The first approach is to use [parametric distributions](https://www.wikiwand.com/en/Parametric_statistics) within nodes that pertain to a continuous variable. This has two disadvantages. First, it is complex because there are different cases to handle: a discrete variable conditioned by a continuous one, a continuous variable conditioned by a discrete one, or combinations of the former with the latter. Secondly, such an approach requires having to pick a parametric distribution for each variable. Although there are methods to automate this choice for you, they are expensive and are far from being foolproof.\n\nThe second approach is to simply discretize the continuous variables. Although this might seem naive, it is generally a good enough approach and definitely makes things simpler implementation-wise. There are many ways to go about discretising a continuous attribute. For instance, you can apply a [quantile-based discretization function](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.qcut.html). You could also round each number to its closest integer. In some cases you might be able to apply a manual rule. For instance, you can convert a numeric temperature to \"cold\", \"mild\", and \"hot\".\n\nTo summarize, we prefer to give the user the flexibility to discretize the variables by herself. Indeed, most of the time the best procedure depends on the problem at hand and cannot be automated adequately.\n\n## Toy networks\n\nSeveral toy networks are available to fool around with in the `examples` submodule:\n\n- 🚨 `alarm` — the alarm network introduced by Judea Pearl.\n- 🐉 `asia` — a popular example introduced in [*Local computations with probabilities on graphical structures and their application to expert systems*](https://www.jstor.org/stable/2345762).\n- 🎓 `grades` — an [example](https://ermongroup.github.io/cs228-notes/representation/directed/) from Stanford's CS 228 class.\n- 💦 `sprinkler` — the network used in chapter 14 of [*Artificial Intelligence: A Modern Approach (3rd edition)*](https://www.google.com/url?sa=t\u0026rct=j\u0026q=\u0026esrc=s\u0026source=web\u0026cd=2\u0026ved=2ahUKEwj5mv3s9rLpAhU3D2MBHc0zARIQFjABegQIAhAB\u0026url=https%3A%2F%2Ffaculty.psau.edu.sa%2Ffiledownload%2Fdoc-7-pdf-a154ffbcec538a4161a406abf62f5b76-original.pdf\u0026usg=AOvVaw0i7pLrlBs9LMW296xeV6b0).\n\nHere is some example usage:\n\n```python\n\u003e\u003e\u003e bn = hh.examples.sprinkler()\n\n\u003e\u003e\u003e bn.nodes\n['Cloudy', 'Rain', 'Sprinkler', 'Wet grass']\n\n\u003e\u003e\u003e pprint(bn.parents)\n{'Rain': ['Cloudy'],\n 'Sprinkler': ['Cloudy'],\n 'Wet grass': ['Rain', 'Sprinkler']}\n\n\u003e\u003e\u003e pprint(bn.children)\n{'Cloudy': ['Rain', 'Sprinkler'],\n 'Rain': ['Wet grass'],\n 'Sprinkler': ['Wet grass']}\n\n```\n\n## Development\n\n```sh\n# Download and navigate to the source code\ngit clone https://github.com/MaxHalford/sorobn\ncd sorobn\n\n# Install poetry\ncurl -sSL https://install.python-poetry.org | python3 -\n\n# Install in development mode\npoetry install\n\n# Run tests\npoetry shell\npytest\n```\n\n## License\n\nThis project is free and open-source software licensed under the [MIT license](https://github.com/MaxHalford/sorobn/blob/master/LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmaxhalford%2Fsorobn","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmaxhalford%2Fsorobn","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmaxhalford%2Fsorobn/lists"}