├── 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 [![Binder](https://mybinder.org/badge_logo.svg)](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 | " \n", 62 | " \n", 63 | " \n", 64 | " \n", 65 | " \n", 66 | " \n", 67 | " \n", 68 | " \n", 69 | " \n", 70 | " \n", 71 | " \n", 72 | " \n", 73 | " \n", 74 | " \n", 75 | " \n", 76 | " \n", 77 | " \n", 78 | " \n", 79 | " \n", 80 | " \n", 81 | " \n", 82 | " \n", 83 | " \n", 84 | " \n", 85 | " \n", 86 | " \n", 87 | " \n", 88 | " \n", 89 | " \n", 90 | " \n", 91 | " \n", 92 | " \n", 93 | " \n", 94 | " \n", 95 | " \n", 96 | " \n", 97 | " \n", 98 | " \n", 99 | " \n", 100 | " \n", 101 | " \n", 102 | " \n", 103 | " \n", 104 | " \n", 105 | " \n", 106 | " \n", 107 | " \n", 108 | " \n", 109 | " \n", 110 | " \n", 111 | " \n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " \n", 116 | " \n", 117 | " \n", 118 | " \n", 119 | " \n", 120 | " \n", 121 | " \n", 122 | " \n", 123 | " \n", 124 | " \n", 125 | " \n", 126 | " \n", 127 | " \n", 128 | " \n", 129 | " \n", 130 | " \n", 131 | " \n", 132 | " \n", 133 | " \n", 134 | "
person_IDnamefirstlastmiddleemailphonefaxtitle
03130Burks, RosellaRosellaBurksNaNBurksR@univ.edu963.555.1253963.777.4065Professor
13297Avila, DamienDamienAvilaNaNAvilaD@univ.edu963.555.1352963.777.7914Professor
23547Olsen, RobinRobinOlsenNaNOlsenR@univ.edu963.555.1378963.777.9262Assistant Professor
31538Moises, Edgar EstesEdgarMoisesEstesMoisesE@univ.edu963.555.2731x3565963.777.8264Professor
42941Brian, Heath PruittHeathBrianPruittBrianH@univ.edu963.555.2800963.777.7249Associate Curator
\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 | " \n", 322 | " \n", 323 | " \n", 324 | " \n", 325 | " \n", 326 | " \n", 327 | " \n", 328 | " \n", 329 | " \n", 330 | " \n", 331 | " \n", 332 | " \n", 333 | " \n", 334 | " \n", 335 | " \n", 336 | " \n", 337 | " \n", 338 | " \n", 339 | " \n", 340 | " \n", 341 | " \n", 342 | " \n", 343 | " \n", 344 | " \n", 345 | " \n", 346 | " \n", 347 | " \n", 348 | " \n", 349 | " \n", 350 | " \n", 351 | " \n", 352 | " \n", 353 | " \n", 354 | " \n", 355 | " \n", 356 | " \n", 357 | " \n", 358 | " \n", 359 | " \n", 360 | " \n", 361 | " \n", 362 | " \n", 363 | " \n", 364 | " \n", 365 | " \n", 366 | " \n", 367 | " \n", 368 | " \n", 369 | " \n", 370 | " \n", 371 | " \n", 372 | " \n", 373 | " \n", 374 | " \n", 375 | " \n", 376 | " \n", 377 | " \n", 378 | " \n", 379 | " \n", 380 | " \n", 381 | " \n", 382 | " \n", 383 | " \n", 384 | " \n", 385 | " \n", 386 | " \n", 387 | " \n", 388 | " \n", 389 | " \n", 390 | " \n", 391 | " \n", 392 | " \n", 393 | " \n", 394 | " \n", 395 | " \n", 396 | " \n", 397 | " \n", 398 | " \n", 399 | " \n", 400 | " \n", 401 | " \n", 402 | " \n", 403 | " \n", 404 | " \n", 405 | " \n", 406 | " \n", 407 | " \n", 408 | " \n", 409 | " \n", 410 | " \n", 411 | " \n", 412 | " \n", 413 | " \n", 414 | " \n", 415 | " \n", 416 | " \n", 417 | " \n", 418 | "
person_IDnamefirstlastmiddleemailphonefaxtitlestreamrankpeoplemanagermanager
03130Burks, RosellaRosellaBurksNaNBurksR@univ.edu963.555.1253963.777.4065ProfessorAcademia031303130
13297Avila, DamienDamienAvilaNaNAvilaD@univ.edu963.555.1352963.777.7914ProfessorAcademia031303130
23547Olsen, RobinRobinOlsenNaNOlsenR@univ.edu963.555.1378963.777.9262Assistant ProfessorAcademia132973297
31538Moises, Edgar EstesEdgarMoisesEstesMoisesE@univ.edu963.555.2731x3565963.777.8264ProfessorAcademia031303130
42941Brian, Heath PruittHeathBrianPruittBrianH@univ.edu963.555.2800963.777.7249Associate CuratorCuration115382941
\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 | " \n", 515 | " \n", 516 | " \n", 517 | " \n", 518 | " \n", 519 | " \n", 520 | " \n", 521 | " \n", 522 | " \n", 523 | " \n", 524 | " \n", 525 | " \n", 526 | " \n", 527 | " \n", 528 | " \n", 529 | " \n", 530 | " \n", 531 | " \n", 532 | " \n", 533 | " \n", 534 | " \n", 535 | " \n", 536 | " \n", 537 | " \n", 538 | " \n", 539 | " \n", 540 | " \n", 541 | " \n", 542 | " \n", 543 | " \n", 544 | " \n", 545 | " \n", 546 | " \n", 547 | " \n", 548 | " \n", 549 | " \n", 550 | " \n", 551 | " \n", 552 | " \n", 553 | " \n", 554 | " \n", 555 | " \n", 556 | " \n", 557 | " \n", 558 | " \n", 559 | " \n", 560 | " \n", 561 | " \n", 562 | " \n", 563 | " \n", 564 | " \n", 565 | " \n", 566 | " \n", 567 | " \n", 568 | " \n", 569 | " \n", 570 | " \n", 571 | " \n", 572 | " \n", 573 | " \n", 574 | " \n", 575 | " \n", 576 | " \n", 577 | " \n", 578 | " \n", 579 | " \n", 580 | " \n", 581 | " \n", 582 | " \n", 583 | " \n", 584 | " \n", 585 | " \n", 586 | " \n", 587 | " \n", 588 | " \n", 589 | " \n", 590 | " \n", 591 | " \n", 592 | " \n", 593 | " \n", 594 | " \n", 595 | " \n", 596 | " \n", 597 | " \n", 598 | " \n", 599 | " \n", 600 | " \n", 601 | " \n", 602 | " \n", 603 | " \n", 604 | " \n", 605 | " \n", 606 | " \n", 607 | " \n", 608 | " \n", 609 | " \n", 610 | " \n", 611 | " \n", 612 | " \n", 613 | " \n", 614 | " \n", 615 | " \n", 616 | " \n", 617 | " \n", 618 | " \n", 619 | " \n", 620 | " \n", 621 | " \n", 622 | " \n", 623 | " \n", 624 | " \n", 625 | " \n", 626 | " \n", 627 | " \n", 628 | " \n", 629 | "
person_IDnamefirstlastmiddleemailphonefaxtitlestreamrankpeoplemanagermanagersubjectssizelabel
03130Burks, RosellaRosellaBurksNaNBurksR@univ.edu963.555.1253963.777.4065ProfessorAcademia031303130[Sports Cars, Science]60Burks
13297Avila, DamienDamienAvilaNaNAvilaD@univ.edu963.555.1352963.777.7914ProfessorAcademia031303130[Science, English]60Avila
23547Olsen, RobinRobinOlsenNaNOlsenR@univ.edu963.555.1378963.777.9262Assistant ProfessorAcademia132973297[Sports Cars, Art]30Olsen
31538Moises, Edgar EstesEdgarMoisesEstesMoisesE@univ.edu963.555.2731x3565963.777.8264ProfessorAcademia031303130[Art, Science]60Moises
42941Brian, Heath PruittHeathBrianPruittBrianH@univ.edu963.555.2800963.777.7249Associate CuratorCuration115382941[Art, Medieval History]30Brian
\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 | --------------------------------------------------------------------------------