├── Gemfile
├── _config.yml
├── people.pkl
├── app.yaml
├── Part 0 - The Data
├── people.pkl
└── README.md
├── utils.py
├── Part 1 - The Graph
└── README.md
├── Part 2 - Clustered Graph Attributes
└── README.md
├── .gcloudignore
├── Pipfile
├── Part 3 - An App
└── README.md
├── .devcontainer
└── devcontainer.json
├── .gitignore
├── requirements.txt
├── Create main.ipynb
├── README.md
├── main.py
└── Dash Cytoscape.ipynb
/Gemfile:
--------------------------------------------------------------------------------
1 | gem "just-the-docs"
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-minimal
--------------------------------------------------------------------------------
/people.pkl:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lucasdurand/network-graph-tutorial/HEAD/people.pkl
--------------------------------------------------------------------------------
/app.yaml:
--------------------------------------------------------------------------------
1 | runtime: python311
2 | entrypoint: gunicorn -b :$PORT main:app --timeout 60 -w 1
3 |
4 | instance_class: F2
--------------------------------------------------------------------------------
/Part 0 - The Data/people.pkl:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lucasdurand/network-graph-tutorial/HEAD/Part 0 - The Data/people.pkl
--------------------------------------------------------------------------------
/utils.py:
--------------------------------------------------------------------------------
1 | from operator import itemgetter
2 | def top_n(d: dict[str, int], *, n:int=1, break_ties:bool=False) -> dict[str, int]:
3 | """Return the top ``n`` entries, optionally breaking ties"""
4 | ranked = sorted(d.items(), key=itemgetter(1), reverse=True)
5 | top = {k:v for k,v in ranked[:n]}
6 | if break_ties: # arbitrarily choose winners
7 | return top
8 | else: # include all qualifiers
9 | good_enough = set(top.values())
10 | return {k:v for k,v in ranked if v in good_enough}
--------------------------------------------------------------------------------
/Part 1 - The Graph/README.md:
--------------------------------------------------------------------------------
1 | # Part 1 - The Graph
2 |
3 | Here we familiarize ourselves with Network Graphs in general and build up a toolkit to explore, manipulate, and visualize graphs
4 |
5 | **Tasks**
6 |
7 | * Build a simple graph with nodes as people and connections as *manages*
8 | * Visualize the graph
9 | * Explore the organization structure using an interactive plot
10 |
11 | * Get started in the [**Workbook**](https://github.dev/lucasdurand/network-graph-tutorial/blob/develop/Part%201%20-%20The%20Graph/Workbook.ipynb)
12 |
13 | **Extras**
--------------------------------------------------------------------------------
/Part 2 - Clustered Graph Attributes/README.md:
--------------------------------------------------------------------------------
1 | # Part 2 - Clustered Graph Attributes
2 |
3 | **Tasks**
4 |
5 | This is a bit of a leap ... but we're going to take our graph toolkit and:
6 | 1. Add in attribute nodes
7 | 2. Visualize *neighborhoods* in the graph
8 |
9 | * Get started in the [**Workbook**](https://github.dev/lucasdurand/network-graph-tutorial/blob/develop/Part%202%20-%20Clustered%20Graph%20Attributes/Workbook.ipynb)
10 |
11 |
12 | **Extras**
13 |
14 | 3. Use `node2vec` + `sklearn.cluster.KMeans` to group nodes into clusters
15 | 4. Use `nx.communities` to detect communities with *dendrograms* and visualize
--------------------------------------------------------------------------------
/.gcloudignore:
--------------------------------------------------------------------------------
1 | # This file specifies files that are *not* uploaded to Google Cloud
2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of
3 | # "#!include" directives (which insert the entries of the given .gitignore-style
4 | # file at that point).
5 | #
6 | # For more information, run:
7 | # $ gcloud topic gcloudignore
8 | #
9 | .gcloudignore
10 | # If you would like to upload your .git directory, .gitignore file or files
11 | # from your .gitignore file, remove the corresponding line
12 | # below:
13 | .git
14 | .gitignore
15 |
16 | # Python pycache:
17 | __pycache__/
18 | # Ignored by the build system
19 | /setup.cfg
--------------------------------------------------------------------------------
/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | name = "pypi"
3 | url = "https://pypi.org/simple"
4 | verify_ssl = true
5 |
6 | [dev-packages]
7 | jupyterlab = "*"
8 | jupyter-dash = "*"
9 | jupyter-server-proxy = "*"
10 | jupyterlab-code-formatter = "*"
11 | black = "*"
12 | isort = "*"
13 | ipykernel = "*"
14 | pylint = "*"
15 | nbformat = "*"
16 |
17 | [packages]
18 | pandas = "*"
19 | dash = "*"
20 | dash-bootstrap-components = "*"
21 | networkx = "*"
22 | dash-cytoscape = "!=0.3.0"
23 | faker = "*"
24 | matplotlib = "*"
25 | more-itertools = "*"
26 | gunicorn = {version = "*", sys_platform = "== 'linux'"}
27 | pyvis = "*"
28 | gravis = "*"
29 | node2vec = "*"
30 |
31 | [requires]
32 | python_version = "3.10"
33 |
--------------------------------------------------------------------------------
/Part 3 - An App/README.md:
--------------------------------------------------------------------------------
1 | # Part 3 - An App
2 |
3 | Now we put it all together into an interactive web app, powered by the network graph toolkit we've been developing
4 |
5 | **Tasks**
6 |
7 | 1. Play with `dash-cytoscape` and build a simple visualization with a subset of data
8 | 2. Apply some simple styles to the data
9 | 2. Add in some interactive components to allow us to choose the attributes that connect and style the graph
10 |
11 | * Get started in the [**Workbook**](https://github.dev/lucasdurand/network-graph-tutorial/blob/develop/Part%203%20-%20An%20App/Workbook.ipynb)
12 |
13 |
14 | **Extras**
15 |
16 | 4. Add additional inputs to allow the full flexibility of the `px_plot_nx` function we made earlier
17 | 4. Visualize the communities we detected
18 | 5. Add pagination / better scaling for larger graphs
--------------------------------------------------------------------------------
/Part 0 - The Data/README.md:
--------------------------------------------------------------------------------
1 | # Part 0 - The Data
2 |
3 | This whole section can be skimmed, but the goal here is to generate usable data that *looks* and *feels* like a real group of people
4 |
5 | **Tasks**
6 |
7 | * Use `Faker` to generate a population of sample people
8 | * Use `numpy.random` and some sweet maths to fill in custom values
9 | * Get all that into a `pd.DataFrame` and figure out a way to assign a manager to everyone
10 | * Finally, hire a CEO and pay them too much
11 |
12 | * Get started in the [**Workbook**](https://github.dev/lucasdurand/network-graph-tutorial/blob/develop/Part%200%20-%20The%20Data/Workbook.ipynb)
13 |
14 | **Extras**
15 |
16 | * Add more information to our people
17 | * Align roles/locations more with reality:
18 | * Executives in headquarters location (with exceptions)
19 | * *Managers* should all be `"Full Time"`
20 | * Merge logic back into `Faker` so that we can keep track of things like unique values and simplify the interface
21 | * What other kinds of organizations can we model? Store each set of generators as a separate `Faker` locale
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python
3 | {
4 | "name": "Python 3",
5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6 | "image": "mcr.microsoft.com/devcontainers/python:0-3.11",
7 | "features": {
8 | "ghcr.io/devcontainers-contrib/features/pipenv:2": {
9 | "version": "latest"
10 | }
11 | }
12 |
13 | // Features to add to the dev container. More info: https://containers.dev/features.
14 | // "features": {},
15 |
16 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
17 | // "forwardPorts": [],
18 |
19 | // Use 'postCreateCommand' to run commands after the container is created.
20 | // "postCreateCommand": "pip3 install --user -r requirements.txt",
21 |
22 | // Configure tool-specific properties.
23 | // "customizations": {},
24 |
25 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
26 | // "remoteUser": "root"
27 | }
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | #
2 | # These requirements were autogenerated by pipenv
3 | # To regenerate from the project's Pipfile, run:
4 | #
5 | # pipenv lock --requirements
6 | #
7 |
8 | -i https://pypi.org/simple
9 | ansi2html==1.8.0; python_version >= '3.6'
10 | asttokens==2.4.1
11 | blinker==1.6.3; python_version >= '3.7'
12 | certifi==2023.7.22; python_version >= '3.6'
13 | charset-normalizer==3.3.1; python_version >= '3.7'
14 | click==8.1.7; python_version >= '3.7'
15 | contourpy==1.1.1; python_version >= '3.8'
16 | cycler==0.12.1; python_version >= '3.8'
17 | dash-bootstrap-components==1.5.0
18 | dash-core-components==2.0.0
19 | dash-cytoscape==0.2.0
20 | dash-html-components==2.0.0
21 | dash-table==5.0.0
22 | dash==2.14.1
23 | decorator==5.1.1; python_version >= '3.5'
24 | executing==2.0.1; python_version >= '3.5'
25 | faker==19.12.0
26 | flask==3.0.0; python_version >= '3.8'
27 | fonttools==4.43.1; python_version >= '3.8'
28 | gunicorn==21.2.0; sys_platform == 'linux'
29 | idna==3.4; python_version >= '3.5'
30 | importlib-metadata==6.8.0; python_version >= '3.7'
31 | ipython==8.17.1; python_version >= '3.9'
32 | itsdangerous==2.1.2; python_version >= '3.7'
33 | jedi==0.19.1; python_version >= '3.6'
34 | jinja2==3.1.2; python_version >= '3.7'
35 | jsonpickle==3.0.2; python_version >= '3.7'
36 | kiwisolver==1.4.5; python_version >= '3.7'
37 | markupsafe==2.1.3; python_version >= '3.7'
38 | matplotlib-inline==0.1.6; python_version >= '3.5'
39 | matplotlib==3.8.0
40 | more-itertools==10.1.0
41 | nest-asyncio==1.5.8; python_version >= '3.5'
42 | networkx==3.2.1
43 | numpy==1.26.1; python_version < '3.13' and python_version >= '3.9'
44 | packaging==23.2; python_version >= '3.7'
45 | pandas==2.1.2
46 | parso==0.8.3; python_version >= '3.6'
47 | pexpect==4.8.0; sys_platform != 'win32'
48 | pillow==10.1.0; python_version >= '3.8'
49 | plotly==5.18.0; python_version >= '3.6'
50 | prompt-toolkit==3.0.39; python_version >= '3.7'
51 | ptyprocess==0.7.0
52 | pure-eval==0.2.2
53 | pygments==2.16.1; python_version >= '3.7'
54 | pyparsing==3.1.1; python_full_version >= '3.6.8'
55 | python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
56 | pytz==2023.3.post1
57 | pyvis==0.3.2
58 | requests==2.31.0; python_version >= '3.7'
59 | retrying==1.3.4
60 | setuptools==68.2.2; python_version >= '3.8'
61 | six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
62 | stack-data==0.6.3
63 | tenacity==8.2.3; python_version >= '3.7'
64 | traitlets==5.13.0; python_version >= '3.8'
65 | typing-extensions==4.8.0; python_version >= '3.8'
66 | tzdata==2023.3; python_version >= '2'
67 | urllib3==2.0.7; python_version >= '3.7'
68 | wcwidth==0.2.9
69 | werkzeug==3.0.1; python_version >= '3.8'
70 | zipp==3.17.0; python_version >= '3.8'
71 |
--------------------------------------------------------------------------------
/Create main.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "attachments": {},
5 | "cell_type": "markdown",
6 | "metadata": {},
7 | "source": [
8 | "# Create Deployable App\n",
9 | "\n",
10 | "Dark paths have brought us here, where we are now combining a bunch of notebooks into a single .py file that we blindly throw at GCP to run. Mistakes were made, people, mistakes were made!"
11 | ]
12 | },
13 | {
14 | "cell_type": "code",
15 | "execution_count": 13,
16 | "metadata": {},
17 | "outputs": [],
18 | "source": [
19 | "import io\n",
20 | "import os\n",
21 | "import sys\n",
22 | "\n",
23 | "import nbformat\n",
24 | "\n",
25 | "def exclude_cells(cells):\n",
26 | " return [cell for cell in cells if \"%run\" not in cell[\"source\"]]\n",
27 | "\n",
28 | "def merge_notebooks(filenames):\n",
29 | " merged = None\n",
30 | " for fname in filenames:\n",
31 | " with io.open(fname, 'r', encoding='utf-8') as f:\n",
32 | " nb = nbformat.read(f, as_version=4)\n",
33 | " if merged is None:\n",
34 | " merged = nb\n",
35 | " else:\n",
36 | " # TODO: add an optional marker between joined notebooks\n",
37 | " # like an horizontal rule, for example, or some other arbitrary\n",
38 | " # (user specified) markdown cell)\n",
39 | " merged.cells.extend(exclude_cells(nb.cells))\n",
40 | " if not hasattr(merged.metadata, 'name'):\n",
41 | " merged.metadata.name = ''\n",
42 | " merged.metadata.name += \"_merged\"\n",
43 | " #print(nbformat.writes(merged))\n",
44 | " return merged"
45 | ]
46 | },
47 | {
48 | "cell_type": "code",
49 | "execution_count": 14,
50 | "metadata": {},
51 | "outputs": [],
52 | "source": [
53 | "bigbook = merge_notebooks([\n",
54 | " \"Part 0 - The Data/Solution.ipynb\", \n",
55 | " \"Part 1 - The Graph/Solution.ipynb\",\n",
56 | " \"Part 2 - Clustered Graph Attributes/Solution.ipynb\",\n",
57 | " \"Part 3 - An App/Solution.ipynb\"\n",
58 | "])\n",
59 | "\n",
60 | "with io.open(\"main.ipynb\", \"w\") as nb:\n",
61 | " nbformat.write(bigbook, nb)\n"
62 | ]
63 | },
64 | {
65 | "cell_type": "code",
66 | "execution_count": 15,
67 | "metadata": {},
68 | "outputs": [
69 | {
70 | "name": "stdout",
71 | "output_type": "stream",
72 | "text": [
73 | "[NbConvertApp] Converting notebook main.ipynb to script\n",
74 | "[NbConvertApp] ERROR | Notebook JSON is invalid: Additional properties are not allowed ('id' was unexpected)\n",
75 | "\n",
76 | "Failed validating 'additionalProperties' in markdown_cell:\n",
77 | "\n",
78 | "On instance['cells'][33]:\n",
79 | "{'attachments': {},\n",
80 | " 'cell_type': 'markdown',\n",
81 | " 'id': 'bb042e3f-caba-44ee-9747-e74d6bebe1e8',\n",
82 | " 'metadata': {},\n",
83 | " 'source': '# Understanding People with Network Graphs in Python\\n'\n",
84 | " '\\n'\n",
85 | " 'Now we can...'}\n",
86 | "[NbConvertApp] Writing 33542 bytes to main.py\n"
87 | ]
88 | }
89 | ],
90 | "source": [
91 | "!jupyter nbconvert --to script main.ipynb"
92 | ]
93 | },
94 | {
95 | "cell_type": "code",
96 | "execution_count": null,
97 | "metadata": {},
98 | "outputs": [],
99 | "source": []
100 | }
101 | ],
102 | "metadata": {
103 | "kernelspec": {
104 | "display_name": "peopleanalytics-hP1UcNMM",
105 | "language": "python",
106 | "name": "python3"
107 | },
108 | "language_info": {
109 | "codemirror_mode": {
110 | "name": "ipython",
111 | "version": 3
112 | },
113 | "file_extension": ".py",
114 | "mimetype": "text/x-python",
115 | "name": "python",
116 | "nbconvert_exporter": "python",
117 | "pygments_lexer": "ipython3",
118 | "version": "3.11.3"
119 | },
120 | "orig_nbformat": 4
121 | },
122 | "nbformat": 4,
123 | "nbformat_minor": 2
124 | }
125 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Building an Interactive Network Graph to Understand Communities
2 |
3 | *A hands-on tutorial originally given for PyData [Seattle](https://seattle2023.pydata.org/cfp/talk/83P9D7/)/[NYC](https://nyc2023.pydata.org/cfp/talk/KXWQGC/)/[Global](https://global2023.pydata.org/cfp/talk/ZG3JH7/), and [PyCon US](https://us.pycon.org/2024/schedule/presentation/91/), where we learn about people, put them into graphs, then build a [fun graph app](https://community-networks-pydata.uc.r.appspot.com/)!*
4 |
5 | For past recordings, try: [Seattle](https://www.youtube.com/watch?v=n0xe7nHd3QA)/[NYC](https://youtube.com/watch?v=3LwxyynEUwQ)/[Global](https://www.youtube.com/watch?v=QUD7zkT_xJY)
6 |
7 | **Watch the video - [it really happened!](https://youtu.be/n0xe7nHd3QA)**
8 |
9 | ## Introduction -- People?!
10 |
11 | **People are hard to understand, developers doubly so! In this tutorial, we will explore how communities form in organizations to develop a better solution than "The Org Chart". We will walk through using a few key Python libraries in the space, develop a toolkit for Clustering Attributed Graphs (more on that later) and build out an extensible interactive dashboard application that promises to take your legacy HR reporting structure to the next level.**
12 |
13 | > In this tutorial, we will develop some fundamental knowledge on Graph Theory and capabilities in using key Python libraries to construct and analyze network graphs, including xarray, networkx, and dash-cytoscape. The goal of this talk is to build the tools you need to launch your own interactive dashboard in Python that can help explore communities of people based on shared characteristics (e.g. programming languages, projects worked on, apps used, management structure). The data we will dig into focuses on building a better understanding of developers + users and how they form communities, but this could just as easily be extended to any social network. The work we do here can be easily extended to your communities and use cases -- let's build something together!
14 |
15 | > This talk is aimed at Pythonistas with beginner+ experience; we talk through some complex libraries and mathematical concepts, but beginners should be able to follow along and still build their understanding (and an app!)
16 |
17 | * Follow along yourself in [](https://mybinder.org/v2/gh/lucasdurand/network-graph-tutorial/HEAD)!
18 | * **OR!** open me in [Codespaces](https://github.com/features/codespaces): https://github.dev/lucasdurand/network-graph-tutorial/tree/main
19 |
20 | ---
21 |
22 | ## Outline
23 |
24 | First, [some slides!](https://docs.google.com/presentation/d/1Dcwspo5mkD8sVzpnLqyyLLwChlDaq4Gs/edit?usp=sharing&ouid=109162096397202966939&rtpof=true&sd=true)
25 |
26 | ### [Data](Part 0 - The Data/README.md)
27 |
28 | * Generate representative sample data with [`Faker`](https://faker.readthedocs.io/en/master/), [`numpy`](https://numpy.org/doc/stable/), [`pandas`](https://pandas.pydata.org/docs/)
29 |
30 | ### [Building a *Simple* Network Graph](Part 1 - The Graph/README.md)
31 |
32 | * Represent people as a network graph in [`networkx`](https://networkx.org/documentation/stable/index.html), visualize the graph with [`plotly`](https://plotly.com/python/) and [`pyvis`](https://pyvis.readthedocs.io/en/latest/index.html)
33 |
34 | ### [Clustered Attribute Graphs](Part 2 - Clustered Graph Attributes/README.md)
35 |
36 | * Introduce node attributes into the graph
37 | * Explore clustering methods with [`networkx`](https://networkx.org/documentation/stable/index.html), [`node2vec`](https://github.com/eliorc/node2vec)
38 | * Using *closeness* and *connectedness* to define communities and find peers with [`sklearn`](https://scikit-learn.org/stable/) and [`networkx.communities`](https://networkx.org/documentation/stable/index.html)
39 |
40 | ### [Exploring Communities with an Interactive App](Part 3 - An App/README.md)
41 |
42 | * Build a an app with [`dash`](https://plotly.com/dash/) and [`dash-cytoscape`](https://dash.plotly.com/cytoscape) to expose our analytics toolkit and explore the communities inside our fictional company
43 |
44 | ## More Things?
45 |
46 | * Deploy your app to *the Cloud!* This repo is set up to push to Google App Engine pretty easily, but give it a go with Heroku, Azure, or whatever the defacto (semi)-free option is these days!
47 | * Build better sample data, try different types of companies, communities, etc. and look for useful patterns
48 | * Add a feature to show how two people are connected (choose two nodes and then draw some/all of the paths between them)
49 | * Visualize larger networks -- our system starts to break down over ~500 nodes and needs pagination + sharding (?)
50 |
51 | ## Deployment
52 |
53 | ### Google App Engine
54 |
55 | 1. Install the Google Cloud SDK https://cloud.google.com/sdk/docs/install-sdk#deb
56 | 2. Initialize the repo `gcloud init` and log in. Create a new project if needed.
57 | 2. Configure your `app.yaml` https://cloud.google.com/appengine/docs/standard/reference/app-yaml?tab=python
58 | 1. Deploy the app `gcloud app deploy`
59 | 1. View it! `gcloud app browse` or -- https://community-networks-pydata.uc.r.appspot.com/
60 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # coding: utf-8
3 |
4 | # # People
5 | #
6 | # What are they? How can we represent people in a meaningful way without having *real* data to draw from. In our organization we should have a few key pieces of information on each person:
7 | #
8 | # * Unique identifier (a name would be great)
9 | # * Office/Location
10 | # * Job Title
11 | # * Team / Line of Business
12 | # * Manager (this is key to understanding how reporting works)
13 | # * Other info: programming languages, apps used, timezone, date hired, projects worked on, ...
14 | #
15 | # ## Faker
16 | #
17 | # Let's generate some fake data! https://faker.readthedocs.io/en/master/
18 |
19 | # In[1]:
20 |
21 |
22 | from faker import Faker
23 | fake = Faker()
24 |
25 | fake.profile() # lots of great stuff in here!
26 |
27 |
28 | # We can also do things like ensure uniqueness for individual entries across all entries
29 |
30 | # In[2]:
31 |
32 |
33 | from faker.exceptions import UniquenessException
34 | try:
35 | for i in range(10):
36 | print(fake.unique.prefix())
37 | except UniquenessException:
38 | print("😟")
39 |
40 |
41 | # Try generating a few people and see if it looks like a good representation of our organization
42 |
43 | # In[3]:
44 |
45 |
46 | ...
47 |
48 |
49 | # This is a good start but ... it's kind of wonky. We have people all over the world with so many different jobs! Let's keep the spirit of this but implement some of our own limitations on fields to ensure things line up with what we'd expect a company org to look like
50 | #
51 |
52 | # First, a few more interesting features: we can also register new `providers` if anything is missing. If needed these can be customized for different locales
53 |
54 | # In[4]:
55 |
56 |
57 | from faker.providers import DynamicProvider
58 |
59 | employment_status_provider = DynamicProvider(
60 | provider_name="employment",
61 | elements=["Full Time", "Part Time", "Contract"],
62 | )
63 |
64 | fake.add_provider(employment_status_provider)
65 |
66 | fake.employment()
67 |
68 |
69 | # We can customize this further by using the `Faker.BaseProvider`
70 |
71 | # In[5]:
72 |
73 |
74 | # first, import a similar Provider or use the default one
75 | from more_itertools import one
76 | from faker.providers import BaseProvider
77 |
78 | # create new provider class
79 | class EmploymentStatus(BaseProvider):
80 | statuses = {"Full Time": 0.7, "Part Time": 0.05, "Contract": 0.3}
81 | def employment(self) -> str:
82 | return one(fake.random.choices(
83 | list(self.statuses),
84 | weights=self.statuses.values()
85 | ))
86 |
87 | # then add new provider to faker instance
88 | fake.add_provider(EmploymentStatus)
89 |
90 | fake.employment()
91 |
92 |
93 | # ### A Tech Focused Person Data
94 |
95 | # To ground us in this task, let's define a new `Person` object that we can fill up with info (and a few other objects):
96 |
97 | # In[6]:
98 |
99 |
100 | from dataclasses import dataclass, field
101 | from typing import Literal
102 | from enum import Enum, auto
103 | import datetime
104 |
105 | class timezone(str, Enum):
106 | EST = auto()
107 | PST = auto()
108 | UTC = auto()
109 |
110 | @dataclass
111 | class Location:
112 | city: str
113 | tz: timezone
114 | country: str
115 |
116 | @dataclass
117 | class Person:
118 | """Someone who works in our company!"""
119 | name: str
120 | hire_date: datetime.date
121 | status: Literal["Full Time", "Part Time", "Contract"]
122 | languages: list[str] = field(default_factory=list)
123 | manager:str = None
124 | team: str = None
125 | title: str = None
126 | location: Location = None
127 |
128 |
129 | # In[7]:
130 |
131 |
132 | Person(name="Employee #1",hire_date=datetime.date.today(), status="Full Time", location=Location("New York", "EST", "USA"))
133 |
134 |
135 | # In[8]:
136 |
137 |
138 | import numpy as np
139 | import random
140 |
141 | def choose_a_few(
142 | options: list[str],
143 | weights: list[int | float] = None,
144 | max_choices: int = None,
145 | min_choices: int = 0,
146 | ) -> list[str]:
147 | """A helpful function to pick a random number of choices from a list of options
148 |
149 | By default skews the weights toward the first options in the list"""
150 | max_choices = np.clip(max_choices or len(options), min_choices, len(options))
151 |
152 | # how many choices will we make this time?
153 | divisor = max_choices * (max_choices + 1) / 2
154 | k_weights = [int(x) / divisor for x in range(max_choices, min_choices-1, -1)]
155 | n_choices = np.random.choice(list(range(min_choices,max_choices+1)), p=k_weights)
156 |
157 | # make the choices
158 | choices = random.choices(options, weights=weights, k=n_choices)
159 | return list(set(choices))
160 |
161 |
162 | # Now to make some people. Let's re-use whatever we can from `Faker` and then add some more of our own fields. We can also extend where needed to keep our code clear and consistent:
163 |
164 | # In[9]:
165 |
166 |
167 | class ProgrammingLanguages(BaseProvider):
168 | languages = {
169 | "Python": 0.25,
170 | "Scala": 0.1,
171 | "Go": 0.08,
172 | "JavaScript": 0.3,
173 | "Java": 0.3,
174 | "Typescript": 0.17,
175 | "Erlang": 0.01,
176 | "Elixir": 0.001,
177 | }
178 | def programming_languages(self) -> str:
179 | return choose_a_few(list(self.languages), weights=self.languages.values())
180 |
181 | fake.add_provider(ProgrammingLanguages)
182 |
183 |
184 | # In[10]:
185 |
186 |
187 | def make_person() -> Person:
188 | return Person(
189 | name = fake.name(),
190 | hire_date = fake.date_between(start_date="-3y", end_date="today"),
191 | status = fake.employment(),
192 | languages = fake.programming_languages(),
193 | team = None, # hrmmmm this is harder
194 | title = None, # let's be smarter with this
195 | location = None, # let's also be smarter with this
196 | )
197 |
198 | make_person()
199 |
200 |
201 | # Now we can generate more complex attributes in a smart way. Let's set up some rules about where offices are, what teams are in which offices, then pick titles based on other info (e.g. Developers probably know at least one language ... )
202 |
203 | # In[11]:
204 |
205 |
206 | TEAM_TITLES:dict[str,list[str]] = {
207 | "DevX": ["Engineer", "Engineer", "Engineer", "Engineer", "Engineer", "AVP"],
208 | "DevOps": ["Engineer", "Senior Engineer", "Manager", "Senior Manager"],
209 | "Sales": ["Associate", "VP"],
210 | "Support": ["Analyst", "Manager"],
211 | "Platform": ["Engineer", "Senior Engineer","Managing Engineer", "AVP", "VP"],
212 | "Product": ["Engineer", "Manager", "Product Owner", "AVP", "VP"],
213 | "Internal Tools": ["Engineer", "Senior Engineer", "Manager", "AVP", "VP"],
214 | "Business": ["Analyst", "Associate", "Vice President", "Director", "Managing Director"]
215 | }
216 |
217 | # codify the hierarchical structure
218 | allowed_teams_per_office = {
219 | "New York": ["Sales", "Product", "Business"],
220 | "Toronto": ["Platform", "Product", "Internal Tools", "Sales", "Business"],
221 | "Fort Lauderdale": ["DevX"],
222 | "Dublin": ["DevOps", "Support"],
223 | "London": ["Sales", "Business"],
224 | "Seattle": ["Internal Tools", "Product", "Platform"],
225 | }
226 | offices = {
227 | location.city: location
228 | for location in [
229 | Location("New York", tz="EST", country="USA"),
230 | Location("Seattle", tz="PST", country="USA"),
231 | Location("Toronto", tz="EST", country="CAN"),
232 | Location("London", tz="UTC", country="GBR"),
233 | Location("Fort Lauderdale", tz="EST", country="USA"),
234 | Location("Dublin", tz="UTC", country="IRL"),
235 | ]
236 | }
237 |
238 | def title_city_team():
239 | # just a few locations
240 | allowed_titles_per_team = TEAM_TITLES
241 | city = random.choice(list(offices))
242 | team = random.choice(allowed_teams_per_office[city])
243 | title = choose_a_few(
244 | allowed_titles_per_team[team], max_choices=1, min_choices=1
245 | ).pop()
246 |
247 | return {
248 | "location": Location(city=city, tz=offices[city].tz, country=offices[city].country),
249 | "title": title,
250 | "team": team,
251 | }
252 |
253 |
254 | title_city_team()
255 |
256 |
257 | # After running this we should have a better balanced org in terms of region + titles. Then we just need to add the connections in -- i.e. who's the boss?!
258 |
259 | # In[12]:
260 |
261 |
262 | def make_person() -> Person:
263 | title_city_team_ = title_city_team()
264 | technical = 1 if "Engineer" in title_city_team_["title"] else 0
265 | return Person(
266 | name = fake.name(),
267 | hire_date = fake.date_between(start_date="-3y", end_date="today").strftime("%Y%m%d"),
268 | status = fake.employment(),
269 | languages = fake.programming_languages(),
270 | **title_city_team_,
271 | )
272 |
273 |
274 | # In[13]:
275 |
276 |
277 | import pandas as pd
278 | people_df = pd.DataFrame((make_person() for _ in range(150)))
279 | people_df.head()
280 |
281 |
282 | # So, let's group by Team and then pick a manager for everyone. Let's use these rules:
283 | #
284 | # * People report to someone of a higher title if possible, else to a peer
285 | # * Reporting happens within a team
286 | # * We already ordered `TEAM_TITLES` based on *rank*
287 | # * Team leads should be listed as reporting to themselves (for now)
288 |
289 | # In[14]:
290 |
291 |
292 | # calculate team ranks
293 | ranks = {team: {title: rank + 1 for rank,title in enumerate(titles)} for team, titles in TEAM_TITLES.items()}
294 | for team in ranks:
295 | people_df.loc[people_df.team==team, "rank"] = people_df.loc[people_df.team==team].title.map(ranks[team])
296 | people_df = people_df.sort_values(by=["team","rank"])
297 | people_df.sample(3)
298 |
299 |
300 | # In[15]:
301 |
302 |
303 | # determine supervisor
304 | def naivereportsto(row, df, allow_peer_reports:bool=False):
305 | supervisor = (
306 | df[(df.index < row.name)].query(f"""rank > {row["rank"]}""").tail(1)["name"]
307 | )
308 | supervisor = supervisor.item() if not supervisor.empty else None
309 | if not supervisor and allow_peer_reports:
310 | peer = df[(df.index < row.name)].query(f"""rank == {row["rank"]}""").head(1)["name"]
311 | peer = peer.item() if not peer.empty else None
312 | return supervisor or peer or row["name"]
313 | return supervisor or row["name"]
314 |
315 |
316 | def reportsto(df, allow_peer_reports:bool):
317 | return df.assign(manager=df.apply(naivereportsto, df=df, allow_peer_reports=allow_peer_reports, axis=1))
318 |
319 |
320 | def supervisors(df, allow_peer_reports:bool):
321 | df = df.groupby("team", group_keys=False).apply(reportsto, allow_peer_reports=allow_peer_reports).reset_index(drop=True)
322 | return df
323 |
324 |
325 | people_df = people_df.pipe(supervisors, allow_peer_reports=True)
326 | people_df.sample(5)
327 |
328 |
329 | # Now we just need a CEO for all the team leads to report to. Set their manager as themselves to help us out later. We need to make sure to include all the other information in the DF that we just generated, namely `rank` and `manager`. Here let's also set the CEO as reporting to themselves
330 |
331 | # In[16]:
332 |
333 |
334 | CEO = make_person().__dict__ | {"team":"CEO", "title":"CEO", "status":"Full Time"}
335 | CEO["location"] = CEO["location"].__dict__
336 | people_df = pd.concat([people_df, pd.DataFrame([CEO])])
337 | CEO_mask = people_df.name==CEO["name"]
338 | people_df.loc[(people_df.manager == people_df.name) | CEO_mask ,"manager"]=CEO["name"]
339 | people_df.loc[CEO_mask, "rank"] = people_df["rank"].max()+1
340 |
341 |
342 | # Alright, we have something now. Does this seems reasonably distributed? Let's use `plotly` to explore our people's dimensions and get a feel for the data
343 |
344 | # In[17]:
345 |
346 |
347 | # let's flatten the nested pieces of the DataFrame (`people_df.location`)
348 | expanded_df = people_df.assign(**people_df.location.apply(pd.Series))
349 | expanded_df
350 |
351 |
352 | # In[18]:
353 |
354 |
355 | import plotly.express as px
356 |
357 | fig = px.bar(
358 | expanded_df,
359 | x="title",
360 | color="team",
361 | hover_name="name",
362 | hover_data=["team", "tz", "city","manager","languages"],
363 | facet_col="country",
364 | template="plotly_dark",
365 | )
366 | fig.update_xaxes(matches=None, title_text=None)
367 |
368 |
369 | # In[ ]:
370 |
371 |
372 |
373 |
374 |
375 | # # Understanding People with Network Graphs in Python
376 | #
377 | # Now we can really start
378 |
379 | # ## NetworkX
380 | #
381 | # > NetworkX is a Python package for the creation, manipulation, and study of the structure, dynamics, and functions of complex networks - https://networkx.org/
382 | #
383 | # NetworkX provides an easy-to-use, *fast*, graph framework to represent relationships in-memory
384 |
385 | # In[1]:
386 |
387 |
388 | import networkx as nx
389 |
390 | G = nx.Graph()
391 |
392 |
393 | # It's easy to load in data one at a time
394 |
395 | # In[2]:
396 |
397 |
398 | G.add_node("Me", type="person", languages=["Python"])
399 |
400 |
401 | # Or from an iterable object
402 |
403 | # In[3]:
404 |
405 |
406 | G.add_nodes_from((
407 | ("You",dict(languages=["Python","Scala"])),
408 | ("Them",dict(languages=["Python","Javascript"]))
409 | ), type="person")
410 |
411 |
412 | # Now we can look at the new data structure and play around with it:
413 |
414 | # In[4]:
415 |
416 |
417 | G.nodes() # show all the node labels
418 |
419 |
420 | # In[5]:
421 |
422 |
423 | G.nodes(data=True) # show all attributes in nodes
424 |
425 |
426 | # In[6]:
427 |
428 |
429 | G.nodes(data="languages") # show a specific attribute
430 |
431 |
432 | # In[7]:
433 |
434 |
435 | G.add_edge("Me","You", label="friends") # add an edge connecting two nodes ...
436 | G.add_edge("You","Them", label="friends") # add an edge connecting two nodes ...
437 |
438 |
439 | # There are lots of common and complex graph analysis functions available to make the most of the data structure
440 |
441 | # In[8]:
442 |
443 |
444 | nx.shortest_path(G, source="Me", target="Them") # find paths between nodes through edges
445 |
446 |
447 | # In[9]:
448 |
449 |
450 | G.adj # find adjacent nodes
451 |
452 |
453 | # And visualizing is built-in with `matplotlib`. We will pretty this up later with `plotly`
454 |
455 | # In[10]:
456 |
457 |
458 | nx.draw(G, with_labels=True)
459 |
460 |
461 | # ### Some People Data
462 | #
463 | # Let's re-use what we made before:
464 |
465 | # ## The Graph
466 | #
467 | # Now we're ready to define and populate the network graph. NetworkX provides helpful methods to populate the structure from an iterable. Here we massage our list of `people` a bit in order to give a unique name to each *node* in the graph:
468 |
469 | # In[12]:
470 |
471 |
472 | from more_itertools import take
473 |
474 |
475 | # In[13]:
476 |
477 |
478 | G = nx.Graph() # a simple undirected graph
479 | G.add_nodes_from(((person["name"], person) for person in expanded_df.to_dict(orient="records")), person=True, type="person")
480 | take(3, G.nodes(data=True)) # we can look at the contents (which should be very familiar!)
481 |
482 |
483 | # ### Visualizing
484 | #
485 | # Graphs lends themselves well to visual representations. NetworkX also makes this easy to do by tapping into Python's workhorse plotting library, `matplotlib`. We will revisit this later with a more dynamic + interactive approach to visualizing, but for the moment this is the fastest way to get things on paper
486 |
487 | # In[14]:
488 |
489 |
490 | import matplotlib.pyplot as plt
491 | nx.draw(G, with_labels=False)
492 |
493 |
494 | # Let's add a bit of color to this by mapping colors to the `person.team`. Pick any colorscale from `px.colors` (or make your own!). Generally the *qualitative* colors look nice, anything designed for *categorical* data
495 |
496 | # In[15]:
497 |
498 |
499 | colors = dict(zip(people_df.team.unique(),px.colors.qualitative.Vivid))
500 | colors
501 |
502 |
503 | # In[16]:
504 |
505 |
506 | # here are some helpful helpers to translate colors
507 | def rgb_to_hex(r, g, b):
508 | return f'#{r:02x}{g:02x}{b:02x}'
509 | def rgb_string_to_tuple(rgb:str) -> tuple[int,int,int]:
510 | return tuple(int(c) for c in rgb.replace("rgb(","").replace(")","").split(","))
511 |
512 |
513 | # Now we can determine what the color should be for each node and pass that into the `nx.draw` call as a list of `node_color`. The easiest way to do this is to use `G.nodes(data=...)` for the attribute you want to extract, which will give you a map from each node to that attribute. `nx` allows you to iterate
514 |
515 | # In[17]:
516 |
517 |
518 | node_colors = [rgb_to_hex(*rgb_string_to_tuple(colors[team])) for _,team in G.nodes(data="team")]
519 | nx.draw(G, node_color=node_colors)
520 |
521 |
522 | # If this doesn't make much sense yet, it's because we haven't connected any of the nodes together. Adding *edges* to the graph will give shape and meaning to the arrangement of nodes. We can do this similarly to how we added edges. Let's start by connecting nodes by the `manager` attribute.
523 | #
524 | # Be sure to only add edges that reference nodes that exist
525 |
526 | # In[18]:
527 |
528 |
529 | G.add_edges_from(G.nodes(data="manager"), label="manager", manager=True)
530 |
531 |
532 | # Now this should look a bit more sensible
533 |
534 | # In[19]:
535 |
536 |
537 | nx.draw(G, node_color=node_colors)
538 |
539 |
540 | # To view more details of the plot, it's useful to switch to an interactive plotting library like `plotly`. Here we provide a helper function for this, but by no means is this perfect/optimized
541 |
542 | # In[20]:
543 |
544 |
545 | import pandas as pd
546 | import plotly.express as px
547 |
548 |
549 | # In[21]:
550 |
551 |
552 | import plotly.graph_objects as go
553 | def px_plot_network_graph_nodes(G:nx.Graph, *, layout=None, **pxkwargs) -> go.Figure:
554 | # generate the x/y coordinates to represent the graph
555 | positions = (layout or nx.spring_layout(G))
556 | # prepare as DataFrame for plotly
557 | df = pd.DataFrame([{"label": k, "x": v[0], "y": v[1], "size":10, **G.nodes(data=True)[k]} for k,v in positions.items()])
558 | for column in df.columns[(df.sample(100, replace=True).applymap(type) == set).any(axis=0)]:
559 | print(f"Coercing column '{column}' to `list`")
560 | df.loc[~df[column].isna(), column] = df.loc[~df[column].isna(),column].apply(list)
561 | # handle missing values for size/color parameter
562 | size = pxkwargs.pop("size", "size")
563 | df[size] = df[size].fillna(df[size].max())
564 | color = pxkwargs.get("color")
565 | df[color] = df[color].fillna(df["type"])
566 | # create figure
567 | fig = px.scatter(df, x="x", y="y", hover_data=df.columns, size=size, **pxkwargs)
568 | fig.update_layout(
569 | xaxis=go.layout.XAxis(visible=False),
570 | yaxis=go.layout.YAxis(visible=False)
571 | )
572 | return fig
573 |
574 |
575 | def px_plot_nx(G:nx.Graph, *, layout=nx.spring_layout, with_edges=False, **nodekwargs) -> go.Figure:
576 | """Draw a graph using ``plotly``
577 |
578 | Kwargs are passed through to `px.scatter` and can be used to control the attributes that
579 | map ``color``, ``size``, ``facet_row``, ... to attributes in the graph nodes
580 |
581 | Notes
582 | -----
583 | Rendering ``with_edges`` is expensive and should be avoided during exploratory plotting
584 | """
585 | # Generate positions, edges
586 | nodes = layout(G)
587 | edges = [{
588 | "x": [nodes[source][0],nodes[target][0]],
589 | "y": [nodes[source][1],nodes[target][1]]} for source, target in G.edges()
590 | ]
591 | # Plot nodes
592 | figure = px_plot_network_graph_nodes(G, layout=nodes, **nodekwargs)
593 | if with_edges: # Add edges to nodes
594 | figure.add_traces([
595 | px.line(
596 | x=edge["x"],
597 | y=edge["y"],
598 | color_discrete_sequence=["grey"],
599 | ).data[0] for edge in edges
600 | ])
601 | figure.data = figure.data[::-1] # shuffle edges behind nodes
602 | return figure
603 |
604 |
605 | # Use this to make a few plots of the graph and verify that:
606 | #
607 | # * The reporting structure makes sense
608 | # * The job titles are distributed as you'd expect
609 | # * The locations make sense
610 | #
611 | # **Note: plotting `with_edges=True` is quite expensive, try toggling it off if you find it bothersome**
612 |
613 | # In[22]:
614 |
615 |
616 | from functools import partial
617 |
618 | layout = (
619 | nx.spring_layout
620 | ) # partial(nx.spring_layout,k=0.1, iterations=20) # or customize how the layout is generated
621 | px_plot_nx(
622 | G,
623 | color="country",
624 | layout=layout,
625 | with_edges=False,
626 | hover_name="name",
627 | size="rank",
628 | template="plotly_dark",
629 | ) # ,text="label")
630 |
631 |
632 | # In[131]:
633 |
634 |
635 | px_plot_nx(
636 | G,
637 | color="team",
638 | layout=layout,
639 | with_edges=False,
640 | hover_name="name",
641 | size="rank",
642 | template="plotly_dark",
643 | ) # ,text="label")
644 |
645 |
646 | # ## Pyvis
647 | #
648 | # Alas -- there is another way to visualize our `networkx` graph using `VisJS` (via [pyvis](https://pyvis.readthedocs.io/en/latest/introduction.html))
649 | #
650 | # Unfortunately the output [won't render in VSCode](https://github.com/microsoft/vscode-jupyter/issues/12689) ... but if you're in Jupyter or view the html file in a browser you're cooking
651 |
652 | # In[27]:
653 |
654 |
655 | from IPython.display import display
656 | from pyvis.network import Network
657 | nt = Network(notebook=True, cdn_resources="in_line", bgcolor="black")
658 | # populates the nodes and edges data structures
659 | nt.from_nx(G)
660 |
661 | #nt.show('nx.html')
662 |
663 |
664 | # `pyvis` will prettify your graph for you if you include attributes:
665 | # * group: is this part of a group? It will be coloured as such
666 | # * title: hover text
667 | # * label: displayed under the node
668 |
669 | # So let's change our attribute name for the reserved keyword `title`
670 |
671 | # In[43]:
672 |
673 |
674 | H = G.copy() # let's make a copy before mutating this
675 | # fix reserved names
676 | nx.set_node_attributes(H, {name: _title for name, _title in G.nodes(data="title")}, "_title")
677 |
678 |
679 | # We can adjust our current graph to render useful information with a fun plotting function
680 |
681 | # In[37]:
682 |
683 |
684 | def nt_show(G: nx.Graph, color:str=None, title:str=None, label:str=None, size:str=None, legend:bool=True, **network_kwargs):
685 | """Draw a graph using ``pyvis``
686 |
687 | Parameters
688 | ----------
689 | color: str
690 | the name of the attribute to color nodes by
691 | title:str
692 | the name of the attribute to generate hover data titles
693 | label:str
694 | the name of attribute to print text labels
695 | legend:bool
696 | whether to include a janky legend
697 | size:str
698 | the attribute to size nodes by
699 | **network_kwargs
700 | passed through to `pyvis.network.Network` and can be used to customize how the plot is rendered
701 |
702 | """
703 | H = G.copy()
704 | nx.set_node_attributes(H, {name: color for name, color in G.nodes(data=color) if color}, "group")
705 | nx.set_node_attributes(H, {name: title for name, title in G.nodes(data=title) if title}, "title")
706 | nx.set_node_attributes(H, {name: label for name, label in G.nodes(data=label) if label}, "label")
707 | nx.set_node_attributes(H, {name: size for name, size in G.nodes(data=size) if size}, "size")
708 | if legend:
709 | add_legend_nodes(H)
710 | default_kwargs = dict(notebook=True, cdn_resources="in_line")
711 | nt = Network(**default_kwargs|network_kwargs)
712 | nt.from_nx(H)
713 | return nt.show("nx.html");
714 |
715 |
716 | def add_legend_nodes(G:nx.Graph):
717 | # Add Legend Nodes
718 | step = 100
719 | x = -500 * 2
720 | y = -500 * 2
721 | groups = set(group for _, group in G.nodes(data="group"))
722 | legend_nodes = [
723 | (
724 | group,
725 | {
726 | 'group': group,
727 | 'label': group,
728 | 'size': 50,
729 | # 'fixed': True, # So that we can move the legend nodes around to arrange them better
730 | 'physics': False,
731 | 'x': f'{x}px',
732 | 'y': f'{y + legend_node*step}px',
733 | 'shape': 'box',
734 | 'widthConstraint': step * 2,
735 | 'font': {'size': 30}
736 | }
737 | )
738 | for legend_node, group in enumerate(groups) if group
739 | ]
740 | G.add_nodes_from(legend_nodes)
741 |
742 |
743 | # In[28]:
744 |
745 |
746 | # nt_show(H, color="team", label="_title", title="city", bgcolor="black")
747 |
748 |
749 | # Now we can take extra info (attributes) of each person (node) and map those onto nodes. This will allow us to connect people *through* common attributes, and not just through relationships like "reporting structure" or "hierarchy"
750 |
751 | # In[2]:
752 |
753 |
754 | from itertools import chain
755 | from more_itertools import always_iterable
756 |
757 | def add_nodes_from_attributes(G: nx.Graph, *, attribute:str, default=[], flag:str):
758 | attributes = {person:attribute for person, attribute in G.nodes(data=attribute, default=default) if attribute}
759 | for attr in set(chain.from_iterable((always_iterable(value) for value in attributes.values()))):
760 | if attr:
761 | G.add_node(attr, **{flag: True, "type":flag})
762 |
763 | add_nodes_from_attributes(G, attribute="languages", flag="language")
764 | add_nodes_from_attributes(G, attribute="city", flag="city")
765 | add_nodes_from_attributes(G, attribute="tz", flag="timezone", default="")
766 |
767 |
768 | # Let's add the edges in now connecting people to the app/language nodes!
769 |
770 | # In[3]:
771 |
772 |
773 | from more_itertools import always_iterable
774 |
775 | def just_people(G):
776 | return lambda n: G.nodes()[n].get("person")
777 |
778 | def add_edges_from_attributes(G:nx.Graph, *, attribute:str, weight:int=1):
779 | nodes = G.nodes()
780 | for name, attributes in nx.subgraph_view(G, filter_node=just_people(G)).nodes(data=attribute):
781 | for attr in always_iterable(attributes):
782 | if attr in nodes:
783 | G.add_edge(name, attr, weight=weight, **{attribute:True})
784 |
785 |
786 | # In[4]:
787 |
788 |
789 | add_edges_from_attributes(G, attribute="languages")
790 | add_edges_from_attributes(G, attribute="tz")
791 | add_edges_from_attributes(G, attribute="city")
792 | #add_edges_from_attributes(G, attribute="manager")
793 |
794 |
795 | # This should look interesting! We used a **force-directed layout** to draw the graph, meaning that the edges between nodes are **pulling** the nodes together in order until they find an equilibrium point. This also takes into account the weights we applied to edges, with higher weighed edges behaving like springs with higher spring constants
796 |
797 | # In[5]:
798 |
799 |
800 | px_plot_nx(G, height=800, hover_name="label", color="team", size="rank", with_edges=False, template="plotly_dark")
801 |
802 |
803 | # In[1]:
804 |
805 |
806 | # nt_show(G, title="label", color="team", size="rank", bgcolor="black")
807 |
808 |
809 | # ## Extras
810 | #
811 | # We can visually look for closeness above, or do it algorithmically. An `ego_graph` will show you all of the other nodes that are within `radius` steps from yourself (through any edge)
812 |
813 | # ```python
814 | # someone = list(G.nodes)[0]
815 | # ego = nx.ego_graph(G, someone, undirected=True, radius=2)
816 | # ego_people = nx.subgraph_view(ego, filter_node=just_people(G)).nodes()
817 | # nx.draw_networkx(ego, nodelist=ego_people)
818 | # ```
819 |
820 | # This information is also easy to recover from the Graph itself
821 |
822 | # ```python
823 | #
824 | # peoplenodes = nx.subgraph_view(ego, filter_node=just_people)
825 | # connectivity = nx.all_pairs_node_connectivity(ego, nbunch=peoplenodes)
826 | # connectivity
827 | # ```
828 |
829 | # We can also look at where nodes get placed in our force-directed graph and look for closeness there
830 |
831 | # ```python
832 | # import numpy as np
833 | # import xarray as xr
834 | #
835 | # positions = {
836 | # name: pos for name, pos in nx.spring_layout(G).items() if name in peoplenodes
837 | # }
838 | # positions = xr.DataArray(
839 | # list(positions.values()),
840 | # coords={"person": list(positions), "position": ["x", "y"]},
841 | # dims=["person", "position"],
842 | # attrs=dict(description="Node locations in a force-directed layout"),
843 | # )
844 | # positions
845 | # ```
846 |
847 | # We can now do some matrix math to find the pairwise euclidean distances between each node!
848 |
849 | # ```python
850 | # similarities = np.sqrt(((positions - positions.rename(person="person2"))**2).sum("position"))
851 | # similarities.name = "distance"
852 | # similarities
853 | # ```
854 |
855 | # ```python
856 | # friends = similarities.sel(person="Lucas").sortby(similarities.sel(person="Lucas"))
857 | # friends[:2].person2
858 | # ```
859 |
860 | # But this is the same approach (in spirit) to representing a graph as vectors in N-dimensional space (only here we do just 2-dim). A more common approach is to use `node2vec` and then look for closeness in the vectors
861 |
862 | # ## A Very Simple ``Dash`` App
863 |
864 | # In[10]:
865 |
866 |
867 | from dash import Dash, html, Output, Input
868 |
869 | app = Dash()
870 |
871 | app.layout = html.Div([
872 | html.H1("A Simple App", id="title"),
873 | html.P("Structure things like you would in HTML"),
874 | html.Button("Click me to do something", id="button"),
875 | ])
876 |
877 | @app.callback(
878 | Output("title", "children"), # change this value
879 | Input("button", "n_clicks"), # when this changes
880 | prevent_initial_call = True
881 | )
882 | def update_title_on_buttonclick(n_clicks):
883 | print(n_clicks) # sure why not
884 | return "A Simple App **That Does Things!**"
885 |
886 | #app.run()
887 |
888 |
889 | # ## A Bit More Complicated App
890 | #
891 | # Let's use bootstrap to spruce this up a bit
892 |
893 | # In[11]:
894 |
895 |
896 | import dash_bootstrap_components as dbc
897 | from dash import dcc
898 | from faker import Faker
899 | fake = Faker()
900 |
901 | app = Dash(external_stylesheets=[dbc.themes.DARKLY])
902 |
903 | app.layout = html.Div(
904 | [
905 | dbc.NavbarSimple(brand="Communities in Network Graphs"),
906 | dbc.Container(
907 | [
908 | dbc.Row(
909 | [
910 | dbc.Col(
911 | [
912 | html.H3("Customize Lorems"),
913 | dbc.InputGroup(
914 | [
915 | dbc.InputGroupText("N Paras: "),
916 | dbc.Input(
917 | value=2, type="number", id="n_paragraphs"
918 | ),
919 | ]
920 | ),
921 | dbc.InputGroup(
922 | [
923 | dbc.InputGroupText("Color: "),
924 | dcc.Dropdown(
925 | value=None,
926 | options=[
927 | {"label": o, "value": f"var(--bs-{o})"}
928 | for o in [
929 | "red",
930 | "green",
931 | "blue",
932 | "black",
933 | "purple"
934 | ]
935 | ],
936 | id="color",
937 | style={"color":"black"} # play nice (ish) with dark themes
938 | ),
939 | ]
940 | ),
941 | ],
942 | width=4,
943 | ),
944 | dbc.Col([html.H2("Lorems Ipsum"), html.P(id="paragraph")], width=8),
945 | ]
946 | )
947 | ]
948 | ),
949 | ]
950 | )
951 |
952 |
953 | @app.callback(
954 | {"text": Output("paragraph", "children"), "style": Output("paragraph", "style")},
955 | Input("n_paragraphs", "value"),
956 | Input("color", "value"),
957 | )
958 | def generate_paragraphs(n, color):
959 | print(n, color)
960 | return dict(text=[html.P(p) for p in fake.paragraphs(nb=n)], style={"color": color})
961 |
962 |
963 | #app.run(debug=False)
964 |
965 |
966 | # ## Cytoscape
967 | #
968 | # Now we can use the `dash-cytoscape` package to display our graph. Let's start with a wireframe layout and then add in the functionality we need:
969 |
970 | # In[7]:
971 |
972 |
973 | import pandas as pd
974 | import networkx as nx
975 |
976 | def create_graph(people_df: pd.DataFrame, attributes:list[str]) -> nx.DiGraph:
977 | G = nx.DiGraph()
978 | G.add_nodes_from(((person["name"], person) for person in expanded_df.to_dict(orient="records")), person=True, type="person")
979 |
980 | for attribute in attributes:
981 | if attribute != "manager":
982 | add_nodes_from_attributes(G, attribute=attribute, flag=f"{attribute}_flag")
983 | add_edges_from_attributes(G, attribute=attribute)
984 |
985 | return G
986 |
987 | def create_elements(attributes: list[str]=[]) -> list[dict]:
988 | """Generate a graph with connecting attributes and serialize it as cytoscape elements"""
989 |
990 | G = create_graph(people_df=people_df, attributes=attributes)
991 | elements = (
992 | nx.cytoscape_data(G)["elements"]["nodes"]
993 | + nx.cytoscape_data(G)["elements"]["edges"]
994 | )
995 | return elements
996 |
997 |
998 | # In[8]:
999 |
1000 |
1001 | def stylesheet_(focus:str=CEO["name"], theme:str="light", color:str=None, show_names:bool=False):
1002 | dark = theme == "dark"
1003 | return [
1004 | {
1005 | "selector": "node",
1006 | "style": {
1007 | "font-size": 50,
1008 | "color": "lightgrey" if dark else "darkgrey"
1009 | }
1010 | },
1011 | {
1012 | "selector": "edge",
1013 | "style": {
1014 | "line-color": "lightgrey" if dark else "darkgrey",
1015 | "color": "lightgrey" if dark else "darkgrey",
1016 | "curve-style": "bezier",
1017 | "label": "data(label)",
1018 | "width": 1,
1019 | "opacity": 0.25,
1020 | "font-size": 10,
1021 | "text-rotation": "autorotate",
1022 | },
1023 | },
1024 | {"selector": "edge[?languages]", "style": {"label": "codes"}},
1025 | {"selector": "edge[?tz]", "style": {"label": "lives in"}},
1026 | {"selector": "edge[?team]", "style": {"label": "belongs to"}},
1027 | {"selector": "edge[?apps]", "style": {"label": "uses"}},
1028 | {"selector": "edge[?manager]", "style": {"label": "manages", "source-arrow-shape":"triangle"}},
1029 | {
1030 | "selector": "node[?person]",
1031 | "style": {
1032 | "label": "data(name)" if show_names else "",
1033 | "background-color": "lightgreen" if dark else "green",
1034 | "width": 25,
1035 | "height": 25,
1036 | "font-size": 16
1037 | },
1038 | },
1039 | {
1040 | "selector": "node[!person]",
1041 | "style": {
1042 | "label": "data(name)",
1043 | "background-color": "white" if dark else "black",
1044 | "width": 5,
1045 | "height": 5,
1046 | },
1047 | },
1048 | {
1049 | "selector": f"node[id='{focus}']",
1050 | "style":{
1051 | "width": 25,
1052 | "height": 25,
1053 | "font-size": 20,
1054 | "color":"skyblue",
1055 | "background-color":"skyblue",
1056 | "z-index":10
1057 | }
1058 | },
1059 | *node_color_stylesheet(attribute=color)
1060 | ]
1061 |
1062 | def node_color_stylesheet(attribute:str) -> dict:
1063 | if not attribute:
1064 | return []
1065 | #colorscale = [f"var(--bs-{color})" for color in ["red","green","blue","pink","purple","yellow","indigo","cyan","orange","teal"]]
1066 | colorscale = [color for color in ["red","green","blue","pink","purple","yellow","indigo","cyan","orange","teal"]]
1067 | colors = dict(zip(expanded_df[attribute].unique(),colorscale))
1068 | return [
1069 | {"selector": f"node[{attribute}='{value}']", "style":{"background-color":color}}
1070 | for value,color in colors.items()
1071 | ]
1072 |
1073 |
1074 | # In[22]:
1075 |
1076 |
1077 | from dash import dash, html, dcc, Input, Output
1078 | import dash_cytoscape as cyto
1079 |
1080 | cyto.load_extra_layouts()
1081 | dashboard = dash.Dash(__name__, external_stylesheets=[dbc.themes.DARKLY])
1082 |
1083 | cyto_layout = {
1084 | "name": "cose",
1085 | "idealEdgeLength": 100,
1086 | "nodeOverlap": 20,
1087 | "refresh": 20,
1088 | "fit": True,
1089 | "padding": 30,
1090 | "randomize": False,
1091 | "componentSpacing": 100,
1092 | "nodeRepulsion": 400000,
1093 | "edgeElasticity": 100,
1094 | "nestingFactor": 5,
1095 | "gravity": 80,
1096 | "numIter": 1000,
1097 | "initialTemp": 200,
1098 | "coolingFactor": 0.95,
1099 | "minTemp": 1.0,
1100 | "nodeDimensionsIncludeLabels": True,
1101 | }
1102 |
1103 | dropdowns = [
1104 | dbc.InputGroup(
1105 | [
1106 | dbc.InputGroupText("Attributes: "),
1107 | dcc.Dropdown(
1108 | value=["city","manager","team"],
1109 | options=[{"label": o, "value": o} for o in expanded_df.columns],
1110 | id="attributes",
1111 | placeholder="attrs as nodes",
1112 | style={
1113 | "color": "black",
1114 | "min-width": "75px",
1115 | }, # play nice (ish) with dark themes
1116 | multi=True,
1117 | ),
1118 | ]
1119 | ),
1120 | dbc.InputGroup(
1121 | [
1122 | dbc.InputGroupText("Color: "),
1123 | dcc.Dropdown(
1124 | value="country",
1125 | options=[{"label": o, "value": o} for o in expanded_df.columns],
1126 | id="color",
1127 | placeholder="attr as colors",
1128 | style={
1129 | "color": "black",
1130 | "min-width": "75px",
1131 | }, # play nice (ish) with dark themes
1132 | ),
1133 | ]
1134 | ),
1135 | ]
1136 |
1137 |
1138 | def layout():
1139 | network = cyto.Cytoscape(
1140 | id="network",
1141 | layout=cyto_layout,
1142 | responsive=True,
1143 | style={"width": "100%", "height": "800px"},
1144 | )
1145 | return html.Div(
1146 | [
1147 | dbc.NavbarSimple(
1148 | [
1149 | dbc.NavItem(
1150 | dbc.NavLink(
1151 | "PyData NYC 2023",
1152 | href="https://nyc2023.pydata.org/cfp/talk/KXWQGC/",
1153 | )
1154 | ),
1155 | dbc.NavItem(
1156 | dbc.NavLink(
1157 | "PyData Seattle 2023",
1158 | href="https://seattle2023.pydata.org/cfp/talk/83P9D7/",
1159 | )
1160 | ),
1161 | dbc.NavItem(
1162 | dbc.NavLink(
1163 | "Network Graph Tutorial",
1164 | href="https://lucasdurand.xyz/network-graph-tutorial",
1165 | )
1166 | ),
1167 | ],
1168 | brand="Peer Finder",
1169 | ),
1170 | dbc.Container(
1171 | [
1172 | dbc.Row(
1173 | [
1174 | dbc.Col([html.H3("Draw Graph by Attributes"),
1175 | *dropdowns],
1176 | width=4,
1177 | style={"background-color": "var(--bs-dark)"},
1178 | ),
1179 | dbc.Col(network, width=8),
1180 | ]
1181 | )
1182 | ]
1183 | ),
1184 | ]
1185 | )
1186 |
1187 |
1188 | dashboard.layout = layout
1189 |
1190 | @dashboard.callback(
1191 | Output("network", "elements"),
1192 | Input("attributes", "value")
1193 | )
1194 | def update_graph(attributes:list):
1195 | return create_elements(attributes=attributes or [])
1196 |
1197 | @dashboard.callback(
1198 | Output("network", "stylesheet"),
1199 | Input("color", "value")
1200 | )
1201 | def update_stylesheet(color:str):
1202 | return stylesheet_(theme="dark", color=color)
1203 |
1204 |
1205 | if __name__ == "__main__":
1206 | dashboard.run(port=16900, debug=True, use_reloader=False)
1207 |
1208 |
1209 | # In[ ]:
1210 |
1211 |
1212 | app = dashboard.server
1213 | print("app loaded")
1214 |
1215 |
1216 | # ## Extras
1217 | #
1218 | # * Change node size based on `rank` using a calculation
1219 | # * Customize the layout
1220 | # * Add a legend to map colours to values
1221 | # * Add a dropdown to choose how to colour nodes
1222 | # * Pick an attribute to color on people nodes like plotly
1223 | # * Color attribute nodes more statically
1224 |
1225 | # In[ ]:
1226 |
1227 |
1228 |
1229 |
1230 |
--------------------------------------------------------------------------------
/Dash Cytoscape.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "markdown",
5 | "id": "d3b6031d-0da8-4093-bcf3-12328b4fd25e",
6 | "metadata": {},
7 | "source": [
8 | "# How Are People Connected?"
9 | ]
10 | },
11 | {
12 | "cell_type": "markdown",
13 | "id": "1c4f8bc7-9db5-463e-8eb1-cbe2eea1fade",
14 | "metadata": {},
15 | "source": [
16 | "## The Data\n",
17 | "\n",
18 | "Just getting the data was difficult!\n",
19 | "\n",
20 | "* PIA concerns\n",
21 | "* consolidating multiple sources\n",
22 | "* highly available?\n",
23 | "* who owns this now?"
24 | ]
25 | },
26 | {
27 | "cell_type": "markdown",
28 | "id": "acd1ebf6-f0de-492f-aea3-351154c5fd98",
29 | "metadata": {},
30 | "source": [
31 | "For here let's use some sample data"
32 | ]
33 | },
34 | {
35 | "cell_type": "code",
36 | "execution_count": 1,
37 | "id": "6dd6b3d4-34fc-408d-ad35-defff6d5309e",
38 | "metadata": {},
39 | "outputs": [
40 | {
41 | "data": {
42 | "text/html": [
43 | "
\n",
44 | "\n",
57 | "
\n",
58 | " \n",
59 | " \n",
60 | " | \n",
61 | " person_ID | \n",
62 | " name | \n",
63 | " first | \n",
64 | " last | \n",
65 | " middle | \n",
66 | " email | \n",
67 | " phone | \n",
68 | " fax | \n",
69 | " title | \n",
70 | "
\n",
71 | " \n",
72 | " \n",
73 | " \n",
74 | " | 0 | \n",
75 | " 3130 | \n",
76 | " Burks, Rosella | \n",
77 | " Rosella | \n",
78 | " Burks | \n",
79 | " NaN | \n",
80 | " BurksR@univ.edu | \n",
81 | " 963.555.1253 | \n",
82 | " 963.777.4065 | \n",
83 | " Professor | \n",
84 | "
\n",
85 | " \n",
86 | " | 1 | \n",
87 | " 3297 | \n",
88 | " Avila, Damien | \n",
89 | " Damien | \n",
90 | " Avila | \n",
91 | " NaN | \n",
92 | " AvilaD@univ.edu | \n",
93 | " 963.555.1352 | \n",
94 | " 963.777.7914 | \n",
95 | " Professor | \n",
96 | "
\n",
97 | " \n",
98 | " | 2 | \n",
99 | " 3547 | \n",
100 | " Olsen, Robin | \n",
101 | " Robin | \n",
102 | " Olsen | \n",
103 | " NaN | \n",
104 | " OlsenR@univ.edu | \n",
105 | " 963.555.1378 | \n",
106 | " 963.777.9262 | \n",
107 | " Assistant Professor | \n",
108 | "
\n",
109 | " \n",
110 | " | 3 | \n",
111 | " 1538 | \n",
112 | " Moises, Edgar Estes | \n",
113 | " Edgar | \n",
114 | " Moises | \n",
115 | " Estes | \n",
116 | " MoisesE@univ.edu | \n",
117 | " 963.555.2731x3565 | \n",
118 | " 963.777.8264 | \n",
119 | " Professor | \n",
120 | "
\n",
121 | " \n",
122 | " | 4 | \n",
123 | " 2941 | \n",
124 | " Brian, Heath Pruitt | \n",
125 | " Heath | \n",
126 | " Brian | \n",
127 | " Pruitt | \n",
128 | " BrianH@univ.edu | \n",
129 | " 963.555.2800 | \n",
130 | " 963.777.7249 | \n",
131 | " Associate Curator | \n",
132 | "
\n",
133 | " \n",
134 | "
\n",
135 | "
"
136 | ],
137 | "text/plain": [
138 | " person_ID name first last middle email \\\n",
139 | "0 3130 Burks, Rosella Rosella Burks NaN BurksR@univ.edu \n",
140 | "1 3297 Avila, Damien Damien Avila NaN AvilaD@univ.edu \n",
141 | "2 3547 Olsen, Robin Robin Olsen NaN OlsenR@univ.edu \n",
142 | "3 1538 Moises, Edgar Estes Edgar Moises Estes MoisesE@univ.edu \n",
143 | "4 2941 Brian, Heath Pruitt Heath Brian Pruitt BrianH@univ.edu \n",
144 | "\n",
145 | " phone fax title \n",
146 | "0 963.555.1253 963.777.4065 Professor \n",
147 | "1 963.555.1352 963.777.7914 Professor \n",
148 | "2 963.555.1378 963.777.9262 Assistant Professor \n",
149 | "3 963.555.2731x3565 963.777.8264 Professor \n",
150 | "4 963.555.2800 963.777.7249 Associate Curator "
151 | ]
152 | },
153 | "execution_count": 1,
154 | "metadata": {},
155 | "output_type": "execute_result"
156 | }
157 | ],
158 | "source": [
159 | "import numpy as np\n",
160 | "import pandas as pd\n",
161 | "\n",
162 | "people = samplepeople = pd.read_csv(\"https://raw.githubusercontent.com/lawlesst/vivo-sample-data/master/data/csv/people.csv\")\n",
163 | "people.title = people.title.str.strip()\n",
164 | "people.head()"
165 | ]
166 | },
167 | {
168 | "cell_type": "markdown",
169 | "id": "4b46050a-4872-4c42-8b2b-ca6a7c8feb87",
170 | "metadata": {},
171 | "source": [
172 | "And add a new column with a relationship to another person. Let's pretend that everyone reports up to someone else in their stream, ideally someone with a \"higher\" title, unless they're all at the top of their profession, and then there is something like a \"Chair\" that they report to."
173 | ]
174 | },
175 | {
176 | "cell_type": "code",
177 | "execution_count": 2,
178 | "id": "babdd445-b233-45be-9a99-778844547eb1",
179 | "metadata": {},
180 | "outputs": [],
181 | "source": [
182 | "people.loc[people.title.str.contains(\"Professor\"), \"stream\"] = \"Academia\"\n",
183 | "people.loc[people.title.str.contains(\"Curator\"), \"stream\"] = \"Curation\""
184 | ]
185 | },
186 | {
187 | "cell_type": "code",
188 | "execution_count": 3,
189 | "id": "a9419b0e-b2d2-4c17-91a0-db2d667c15aa",
190 | "metadata": {},
191 | "outputs": [],
192 | "source": [
193 | "stream_ranks = [[\"Professor\", \"Assistant Professor\", \"Research Professor\"], [\"Curator\", \"Associate Curator\"]]"
194 | ]
195 | },
196 | {
197 | "cell_type": "code",
198 | "execution_count": 4,
199 | "id": "e40a961a-1adf-4728-b27a-246d2ce52040",
200 | "metadata": {},
201 | "outputs": [
202 | {
203 | "data": {
204 | "text/plain": [
205 | "{'Professor': 0,\n",
206 | " 'Assistant Professor': 1,\n",
207 | " 'Research Professor': 2,\n",
208 | " 'Curator': 0,\n",
209 | " 'Associate Curator': 1}"
210 | ]
211 | },
212 | "execution_count": 4,
213 | "metadata": {},
214 | "output_type": "execute_result"
215 | }
216 | ],
217 | "source": [
218 | "ranks = {title: rank for stream in stream_ranks for rank,title in enumerate(stream)}\n",
219 | "ranks"
220 | ]
221 | },
222 | {
223 | "cell_type": "code",
224 | "execution_count": 5,
225 | "id": "42b4515b-8d66-495a-809f-dc923abd8344",
226 | "metadata": {},
227 | "outputs": [],
228 | "source": [
229 | "people[\"rank\"] = people.title.map(ranks)"
230 | ]
231 | },
232 | {
233 | "cell_type": "markdown",
234 | "id": "68a2f8f1-e34c-481e-8c09-f718ed0ec1aa",
235 | "metadata": {},
236 | "source": [
237 | "### Generate Supervisory Org"
238 | ]
239 | },
240 | {
241 | "cell_type": "code",
242 | "execution_count": 6,
243 | "id": "70f38f14-5e7c-4621-ac0c-dc20b1eff920",
244 | "metadata": {},
245 | "outputs": [],
246 | "source": [
247 | "def naivereportsto(row, df):\n",
248 | " supervisor = df[(df.index < row.name)].query(f\"\"\"rank <= {row[\"rank\"]}-1\"\"\").tail(1).person_ID\n",
249 | " supervisor = supervisor.item() if not supervisor.empty else None\n",
250 | " peer = df[(df.index < row.name)].query(f\"\"\"rank == {row[\"rank\"]}\"\"\").head(1).person_ID\n",
251 | " peer = peer.item() if not peer.empty else None\n",
252 | " return supervisor or peer or row.person_ID"
253 | ]
254 | },
255 | {
256 | "cell_type": "code",
257 | "execution_count": 7,
258 | "id": "01794e23-470d-4841-8038-63269656018d",
259 | "metadata": {},
260 | "outputs": [],
261 | "source": [
262 | "def reportsto(df):\n",
263 | " return df.assign(manager=df.apply(naivereportsto, df=df, axis=1))"
264 | ]
265 | },
266 | {
267 | "cell_type": "code",
268 | "execution_count": 8,
269 | "id": "0ae76d93-9c85-44c9-8671-e6e30bb17fdf",
270 | "metadata": {},
271 | "outputs": [],
272 | "source": [
273 | "def supervisors(df):\n",
274 | " df[\"peoplemanager\"]=df.apply(naivereportsto, df=df, axis=1)\n",
275 | " df = df.groupby('stream').apply(reportsto).reset_index(drop=True)\n",
276 | " return df"
277 | ]
278 | },
279 | {
280 | "cell_type": "code",
281 | "execution_count": 9,
282 | "id": "efd42494-974b-49fb-8d15-c3aa3d39fac1",
283 | "metadata": {},
284 | "outputs": [
285 | {
286 | "name": "stderr",
287 | "output_type": "stream",
288 | "text": [
289 | "/tmp/ipykernel_52180/542737903.py:3: FutureWarning: Not prepending group keys to the result index of transform-like apply. In the future, the group keys will be included in the index, regardless of whether the applied function returns a like-indexed object.\n",
290 | "To preserve the previous behavior, use\n",
291 | "\n",
292 | "\t>>> .groupby(..., group_keys=False)\n",
293 | "\n",
294 | "To adopt the future behavior and silence this warning, use \n",
295 | "\n",
296 | "\t>>> .groupby(..., group_keys=True)\n",
297 | " df = df.groupby('stream').apply(reportsto).reset_index(drop=True)\n"
298 | ]
299 | },
300 | {
301 | "data": {
302 | "text/html": [
303 | "\n",
304 | "\n",
317 | "
\n",
318 | " \n",
319 | " \n",
320 | " | \n",
321 | " person_ID | \n",
322 | " name | \n",
323 | " first | \n",
324 | " last | \n",
325 | " middle | \n",
326 | " email | \n",
327 | " phone | \n",
328 | " fax | \n",
329 | " title | \n",
330 | " stream | \n",
331 | " rank | \n",
332 | " peoplemanager | \n",
333 | " manager | \n",
334 | "
\n",
335 | " \n",
336 | " \n",
337 | " \n",
338 | " | 0 | \n",
339 | " 3130 | \n",
340 | " Burks, Rosella | \n",
341 | " Rosella | \n",
342 | " Burks | \n",
343 | " NaN | \n",
344 | " BurksR@univ.edu | \n",
345 | " 963.555.1253 | \n",
346 | " 963.777.4065 | \n",
347 | " Professor | \n",
348 | " Academia | \n",
349 | " 0 | \n",
350 | " 3130 | \n",
351 | " 3130 | \n",
352 | "
\n",
353 | " \n",
354 | " | 1 | \n",
355 | " 3297 | \n",
356 | " Avila, Damien | \n",
357 | " Damien | \n",
358 | " Avila | \n",
359 | " NaN | \n",
360 | " AvilaD@univ.edu | \n",
361 | " 963.555.1352 | \n",
362 | " 963.777.7914 | \n",
363 | " Professor | \n",
364 | " Academia | \n",
365 | " 0 | \n",
366 | " 3130 | \n",
367 | " 3130 | \n",
368 | "
\n",
369 | " \n",
370 | " | 2 | \n",
371 | " 3547 | \n",
372 | " Olsen, Robin | \n",
373 | " Robin | \n",
374 | " Olsen | \n",
375 | " NaN | \n",
376 | " OlsenR@univ.edu | \n",
377 | " 963.555.1378 | \n",
378 | " 963.777.9262 | \n",
379 | " Assistant Professor | \n",
380 | " Academia | \n",
381 | " 1 | \n",
382 | " 3297 | \n",
383 | " 3297 | \n",
384 | "
\n",
385 | " \n",
386 | " | 3 | \n",
387 | " 1538 | \n",
388 | " Moises, Edgar Estes | \n",
389 | " Edgar | \n",
390 | " Moises | \n",
391 | " Estes | \n",
392 | " MoisesE@univ.edu | \n",
393 | " 963.555.2731x3565 | \n",
394 | " 963.777.8264 | \n",
395 | " Professor | \n",
396 | " Academia | \n",
397 | " 0 | \n",
398 | " 3130 | \n",
399 | " 3130 | \n",
400 | "
\n",
401 | " \n",
402 | " | 4 | \n",
403 | " 2941 | \n",
404 | " Brian, Heath Pruitt | \n",
405 | " Heath | \n",
406 | " Brian | \n",
407 | " Pruitt | \n",
408 | " BrianH@univ.edu | \n",
409 | " 963.555.2800 | \n",
410 | " 963.777.7249 | \n",
411 | " Associate Curator | \n",
412 | " Curation | \n",
413 | " 1 | \n",
414 | " 1538 | \n",
415 | " 2941 | \n",
416 | "
\n",
417 | " \n",
418 | "
\n",
419 | "
"
420 | ],
421 | "text/plain": [
422 | " person_ID name first last middle email \\\n",
423 | "0 3130 Burks, Rosella Rosella Burks NaN BurksR@univ.edu \n",
424 | "1 3297 Avila, Damien Damien Avila NaN AvilaD@univ.edu \n",
425 | "2 3547 Olsen, Robin Robin Olsen NaN OlsenR@univ.edu \n",
426 | "3 1538 Moises, Edgar Estes Edgar Moises Estes MoisesE@univ.edu \n",
427 | "4 2941 Brian, Heath Pruitt Heath Brian Pruitt BrianH@univ.edu \n",
428 | "\n",
429 | " phone fax title stream rank \\\n",
430 | "0 963.555.1253 963.777.4065 Professor Academia 0 \n",
431 | "1 963.555.1352 963.777.7914 Professor Academia 0 \n",
432 | "2 963.555.1378 963.777.9262 Assistant Professor Academia 1 \n",
433 | "3 963.555.2731x3565 963.777.8264 Professor Academia 0 \n",
434 | "4 963.555.2800 963.777.7249 Associate Curator Curation 1 \n",
435 | "\n",
436 | " peoplemanager manager \n",
437 | "0 3130 3130 \n",
438 | "1 3130 3130 \n",
439 | "2 3297 3297 \n",
440 | "3 3130 3130 \n",
441 | "4 1538 2941 "
442 | ]
443 | },
444 | "execution_count": 9,
445 | "metadata": {},
446 | "output_type": "execute_result"
447 | }
448 | ],
449 | "source": [
450 | "people = people.pipe(supervisors)\n",
451 | "people.head(5)"
452 | ]
453 | },
454 | {
455 | "cell_type": "markdown",
456 | "id": "4fca8280-e40a-4f1e-ae66-c80aa06168d2",
457 | "metadata": {},
458 | "source": [
459 | "Now let's turn this into a timeseries of data by semi-randomly giving people awards (from other people) over time"
460 | ]
461 | },
462 | {
463 | "cell_type": "markdown",
464 | "id": "6ad95e93-1a5d-4919-83ec-46e7c47d8bd9",
465 | "metadata": {},
466 | "source": [
467 | "And let's add some more information about the individuals by giving everyone a list of subjects of speciality"
468 | ]
469 | },
470 | {
471 | "cell_type": "code",
472 | "execution_count": 60,
473 | "id": "558a8386-88ce-44e1-bf93-751a91109a67",
474 | "metadata": {},
475 | "outputs": [],
476 | "source": [
477 | "subjects = [\"Art\",\"English\",\"Science\",\"Medieval History\",\"Sports Cars\"]\n",
478 | "\n",
479 | "people[\"subjects\"] = list(np.random.choice(subjects, size=(len(people), 2)))\n",
480 | "\n",
481 | "depth = people[\"rank\"].max()\n",
482 | "size = 30\n",
483 | "people[\"size\"] = size*(depth - people[\"rank\"])\n",
484 | "people[\"label\"] = people[\"last\"]"
485 | ]
486 | },
487 | {
488 | "cell_type": "code",
489 | "execution_count": 61,
490 | "id": "a40cead7-1da8-42c9-97b4-19f286e745ac",
491 | "metadata": {},
492 | "outputs": [
493 | {
494 | "data": {
495 | "text/html": [
496 | "\n",
497 | "\n",
510 | "
\n",
511 | " \n",
512 | " \n",
513 | " | \n",
514 | " person_ID | \n",
515 | " name | \n",
516 | " first | \n",
517 | " last | \n",
518 | " middle | \n",
519 | " email | \n",
520 | " phone | \n",
521 | " fax | \n",
522 | " title | \n",
523 | " stream | \n",
524 | " rank | \n",
525 | " peoplemanager | \n",
526 | " manager | \n",
527 | " subjects | \n",
528 | " size | \n",
529 | " label | \n",
530 | "
\n",
531 | " \n",
532 | " \n",
533 | " \n",
534 | " | 0 | \n",
535 | " 3130 | \n",
536 | " Burks, Rosella | \n",
537 | " Rosella | \n",
538 | " Burks | \n",
539 | " NaN | \n",
540 | " BurksR@univ.edu | \n",
541 | " 963.555.1253 | \n",
542 | " 963.777.4065 | \n",
543 | " Professor | \n",
544 | " Academia | \n",
545 | " 0 | \n",
546 | " 3130 | \n",
547 | " 3130 | \n",
548 | " [Sports Cars, Science] | \n",
549 | " 60 | \n",
550 | " Burks | \n",
551 | "
\n",
552 | " \n",
553 | " | 1 | \n",
554 | " 3297 | \n",
555 | " Avila, Damien | \n",
556 | " Damien | \n",
557 | " Avila | \n",
558 | " NaN | \n",
559 | " AvilaD@univ.edu | \n",
560 | " 963.555.1352 | \n",
561 | " 963.777.7914 | \n",
562 | " Professor | \n",
563 | " Academia | \n",
564 | " 0 | \n",
565 | " 3130 | \n",
566 | " 3130 | \n",
567 | " [Science, English] | \n",
568 | " 60 | \n",
569 | " Avila | \n",
570 | "
\n",
571 | " \n",
572 | " | 2 | \n",
573 | " 3547 | \n",
574 | " Olsen, Robin | \n",
575 | " Robin | \n",
576 | " Olsen | \n",
577 | " NaN | \n",
578 | " OlsenR@univ.edu | \n",
579 | " 963.555.1378 | \n",
580 | " 963.777.9262 | \n",
581 | " Assistant Professor | \n",
582 | " Academia | \n",
583 | " 1 | \n",
584 | " 3297 | \n",
585 | " 3297 | \n",
586 | " [Sports Cars, Art] | \n",
587 | " 30 | \n",
588 | " Olsen | \n",
589 | "
\n",
590 | " \n",
591 | " | 3 | \n",
592 | " 1538 | \n",
593 | " Moises, Edgar Estes | \n",
594 | " Edgar | \n",
595 | " Moises | \n",
596 | " Estes | \n",
597 | " MoisesE@univ.edu | \n",
598 | " 963.555.2731x3565 | \n",
599 | " 963.777.8264 | \n",
600 | " Professor | \n",
601 | " Academia | \n",
602 | " 0 | \n",
603 | " 3130 | \n",
604 | " 3130 | \n",
605 | " [Art, Science] | \n",
606 | " 60 | \n",
607 | " Moises | \n",
608 | "
\n",
609 | " \n",
610 | " | 4 | \n",
611 | " 2941 | \n",
612 | " Brian, Heath Pruitt | \n",
613 | " Heath | \n",
614 | " Brian | \n",
615 | " Pruitt | \n",
616 | " BrianH@univ.edu | \n",
617 | " 963.555.2800 | \n",
618 | " 963.777.7249 | \n",
619 | " Associate Curator | \n",
620 | " Curation | \n",
621 | " 1 | \n",
622 | " 1538 | \n",
623 | " 2941 | \n",
624 | " [Art, Medieval History] | \n",
625 | " 30 | \n",
626 | " Brian | \n",
627 | "
\n",
628 | " \n",
629 | "
\n",
630 | "
"
631 | ],
632 | "text/plain": [
633 | " person_ID name first last middle email \\\n",
634 | "0 3130 Burks, Rosella Rosella Burks NaN BurksR@univ.edu \n",
635 | "1 3297 Avila, Damien Damien Avila NaN AvilaD@univ.edu \n",
636 | "2 3547 Olsen, Robin Robin Olsen NaN OlsenR@univ.edu \n",
637 | "3 1538 Moises, Edgar Estes Edgar Moises Estes MoisesE@univ.edu \n",
638 | "4 2941 Brian, Heath Pruitt Heath Brian Pruitt BrianH@univ.edu \n",
639 | "\n",
640 | " phone fax title stream rank \\\n",
641 | "0 963.555.1253 963.777.4065 Professor Academia 0 \n",
642 | "1 963.555.1352 963.777.7914 Professor Academia 0 \n",
643 | "2 963.555.1378 963.777.9262 Assistant Professor Academia 1 \n",
644 | "3 963.555.2731x3565 963.777.8264 Professor Academia 0 \n",
645 | "4 963.555.2800 963.777.7249 Associate Curator Curation 1 \n",
646 | "\n",
647 | " peoplemanager manager subjects size label \n",
648 | "0 3130 3130 [Sports Cars, Science] 60 Burks \n",
649 | "1 3130 3130 [Science, English] 60 Avila \n",
650 | "2 3297 3297 [Sports Cars, Art] 30 Olsen \n",
651 | "3 3130 3130 [Art, Science] 60 Moises \n",
652 | "4 1538 2941 [Art, Medieval History] 30 Brian "
653 | ]
654 | },
655 | "execution_count": 61,
656 | "metadata": {},
657 | "output_type": "execute_result"
658 | }
659 | ],
660 | "source": [
661 | "people.head(5)"
662 | ]
663 | },
664 | {
665 | "cell_type": "markdown",
666 | "id": "d84c06ff-5372-482c-bd0b-ba735bb98f94",
667 | "metadata": {},
668 | "source": [
669 | "## Build The Network"
670 | ]
671 | },
672 | {
673 | "cell_type": "code",
674 | "execution_count": 72,
675 | "id": "f66d421f",
676 | "metadata": {},
677 | "outputs": [
678 | {
679 | "data": {
680 | "image/png": "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uqIckVNan6THrJgUmCySVZt1NKIArShg7ogQhkkiInIKDJRENsjMLcTM6GQkpOdAIwq1Bsvy6/2CfDE/KpTT3ERE5DQYKIlkkJaVh3WJesSnZkNvKMSNP1QCyg4tj+jsh3F9dNzNTUREToeBkkhmBUYTzhkKUGKywF0rIsDHixVwiIjIqTFQEhEREZFNuMubiIiIiGzCQElERERENmGgJCIiIiKbMFASERERkU0YKImIiIjIJgyURERERGQTBkoiIiIisgkDJRERERHZhIGSiIiIiGzCQElERERENmGgJCIiIiKbMFASERERkU0YKImIiIjIJgyURERERGQTBkoiIiIisgkDJRERERHZhIGSiIiIiGzCQElERERENmGgJCIiIiKbMFASERERkU0YKImIiIjIJgyURERERGQTBkoiIiIisgkDJRERERHZhIGSiIiIiGzCQElERERENmGgJCIiIiKbMFASERERkU0YKImIiIjIJgyURERERGQTBkoiIiIisgkDJRERERHZhIGSiIiIiGzCQElERERENmGgJCIiIiKbMFASERERkU0YKImIiIjIJv8POYzmH9xDkycAAAAASUVORK5CYII=",
681 | "text/plain": [
682 | ""
683 | ]
684 | },
685 | "metadata": {},
686 | "output_type": "display_data"
687 | }
688 | ],
689 | "source": [
690 | "import networkx as nx\n",
691 | "\n",
692 | "G = nx.DiGraph()\n",
693 | "G.add_nodes_from(\n",
694 | " ((person[\"person_ID\"], person) for person in people.to_dict(orient=\"records\")),\n",
695 | " type=\"person\",\n",
696 | ")\n",
697 | "G.add_edges_from(G.nodes(data=\"manager\"), label=\"manager\")\n",
698 | "\n",
699 | "nx.draw(G)"
700 | ]
701 | },
702 | {
703 | "cell_type": "code",
704 | "execution_count": null,
705 | "id": "53d3ac90",
706 | "metadata": {},
707 | "outputs": [],
708 | "source": [
709 | "elements = (\n",
710 | " nx.cytoscape_data(G)[\"elements\"][\"nodes\"]\n",
711 | " + nx.cytoscape_data(G)[\"elements\"][\"edges\"]\n",
712 | ")"
713 | ]
714 | },
715 | {
716 | "cell_type": "markdown",
717 | "id": "974da474-2013-416b-8c2c-bc20d648bc80",
718 | "metadata": {},
719 | "source": [
720 | "## Dash"
721 | ]
722 | },
723 | {
724 | "cell_type": "code",
725 | "execution_count": 63,
726 | "id": "2965ea1a-f559-4d9d-854c-b61e0c156465",
727 | "metadata": {},
728 | "outputs": [],
729 | "source": [
730 | "from dash import dash, html, dcc, Input, Output\n",
731 | "# from jupyter_dash import JupyterDash\n",
732 | "import dash_cytoscape as cyto\n",
733 | "# JupyterDash.infer_jupyter_proxy_config()\n",
734 | "cyto.load_extra_layouts()\n",
735 | "dashboard = dash.Dash(__name__)\n",
736 | "#dashboard = JupyterDash(__name__)"
737 | ]
738 | },
739 | {
740 | "cell_type": "code",
741 | "execution_count": 64,
742 | "id": "25727331-d4a4-47e2-a1d6-fdda2aa8d5e8",
743 | "metadata": {},
744 | "outputs": [],
745 | "source": [
746 | "stylesheet = [\n",
747 | " # Group selectors\n",
748 | " {\"selector\": \"node\", \"style\": {\"content\": \"data(label)\", \"width\":\"data(size)\", \"height\":\"data(size)\"}},\n",
749 | " {\"selector\": \"\"\"[stream = \"Academia\"]\"\"\", \"style\":{\"background-color\":\"blue\"}},\n",
750 | " {\"selector\": \"\"\"[stream = \"Curation\"]\"\"\", \"style\":{\"background-color\":\"green\"}},\n",
751 | " # Edge selectors\n",
752 | " {\n",
753 | " \"selector\": \"edge\",\n",
754 | " \"style\": {\n",
755 | " \"content\": \"data(label)\",\n",
756 | " \"curve-style\": \"bezier\",\n",
757 | " \"line-color\": \"gray\",\n",
758 | " \"source-arrow-shape\":\"triangle\",\n",
759 | " \"font-size\":\"10px\",\n",
760 | " },\n",
761 | " },\n",
762 | "]"
763 | ]
764 | },
765 | {
766 | "cell_type": "code",
767 | "execution_count": 65,
768 | "id": "60e092be-a68c-4bfc-83e0-24457531a253",
769 | "metadata": {},
770 | "outputs": [],
771 | "source": [
772 | "def radial_circles(colours):\n",
773 | " stylesheet = {}\n",
774 | " return stylesheet"
775 | ]
776 | },
777 | {
778 | "cell_type": "code",
779 | "execution_count": 66,
780 | "id": "cc6a4af6-bf5e-4279-ac5d-7db11e9691a1",
781 | "metadata": {},
782 | "outputs": [],
783 | "source": [
784 | "lyt = \"cose-bilkent\"\n",
785 | "#lyt = \"spread\"\n",
786 | "#lyt = \"klay\""
787 | ]
788 | },
789 | {
790 | "cell_type": "code",
791 | "execution_count": 67,
792 | "id": "dcb7e459-9b58-4aec-a117-56cbd40b1e67",
793 | "metadata": {},
794 | "outputs": [],
795 | "source": [
796 | "def layout():\n",
797 | " network = cyto.Cytoscape(\n",
798 | " id=\"network\",\n",
799 | " layout={\"name\": lyt},\n",
800 | " style={\"width\": \"100%\", \"height\": \"800px\"},\n",
801 | " elements=elements,\n",
802 | " stylesheet=stylesheet\n",
803 | " )\n",
804 | " return html.Div([\n",
805 | " html.H1(\"The University\"),\n",
806 | " network\n",
807 | " ])\n",
808 | "\n",
809 | "\n",
810 | "dashboard.layout = layout"
811 | ]
812 | },
813 | {
814 | "cell_type": "code",
815 | "execution_count": 68,
816 | "id": "471d45b3-8377-4b9f-b16c-4fc510303707",
817 | "metadata": {},
818 | "outputs": [
819 | {
820 | "name": "stdout",
821 | "output_type": "stream",
822 | "text": [
823 | "Dash is running on http://127.0.0.1:16900/\n",
824 | "\n",
825 | " * Serving Flask app '__main__'\n",
826 | " * Debug mode: on\n"
827 | ]
828 | }
829 | ],
830 | "source": [
831 | "if __name__ == \"__main__\":\n",
832 | " dashboard.run_server(port=16900, debug=True, use_reloader=False)\n",
833 | "else:\n",
834 | " app = dashboard.server"
835 | ]
836 | },
837 | {
838 | "cell_type": "code",
839 | "execution_count": null,
840 | "id": "edc62f24",
841 | "metadata": {},
842 | "outputs": [],
843 | "source": []
844 | }
845 | ],
846 | "metadata": {
847 | "kernelspec": {
848 | "display_name": "peopleanalytics-hP1UcNMM",
849 | "language": "python",
850 | "name": "python3"
851 | },
852 | "language_info": {
853 | "codemirror_mode": {
854 | "name": "ipython",
855 | "version": 3
856 | },
857 | "file_extension": ".py",
858 | "mimetype": "text/x-python",
859 | "name": "python",
860 | "nbconvert_exporter": "python",
861 | "pygments_lexer": "ipython3",
862 | "version": "3.11.2"
863 | },
864 | "vscode": {
865 | "interpreter": {
866 | "hash": "a79ebb34bd880ae1609598d91f5437f974a2fc812a889a38194f0c3042249b1f"
867 | }
868 | }
869 | },
870 | "nbformat": 4,
871 | "nbformat_minor": 5
872 | }
873 |
--------------------------------------------------------------------------------