Wer in seinem Unternehmen Business-Intelligence (BI) betreiben will, findet heute einige ausgereifte Software-Angebote wie QlikView oder Tableau, die ihre nach festem Zeitplan generierten Dashboards meist Web-basiert bequem dahin bringen, wo sie benötigt werden. Mir begegnen in meinem Arbeitsalltag bei PAYMILL aber viele Fälle, in denen dieser Weg umständlich ist oder gar Probleme bereitet:
- Reports mit komplexer Datenaggregation sind entweder aufwändig zu erstellen oder erfordern eine Vorprozessierung der Daten.
- Die Lizenzvergabe schränkt die Zugänglichkeit ein.
- Reports werden nur für einzelne Kunden oder bestimmte Monate verlangt. Das Bereithalten aller möglichen Fälle kann sehr viele Ressourcen binden.
Hinzu kommt, dass meine Kollegen oft aus komplexen Daten simple Tabellen und/oder Diagramme benötigen, bei denen die BI-Suites keinen Mehrwert bieten, aber die Portierung aus meinem Analyseweg mit Python sehr viel Mehrarbeit bereitet. Es liegt daher nahe, stattdessen den direkten Weg zu nehmen: Das Resultat der Analyse wird direkt per Link im Intranet verfügbar gemacht – und in einem zweiten Schritt wird ein Webinterface angeboten, um wiederkehrende Analysen mit veränderlichen Parametern (anderer Monat, anderer Kunde) on-demand zu generieren.
Dieser Artikel beschäftigt sich zunächst damit, wie man aus einigen gängigen Python-Werkzeugen HTML generiert, um dann an einem einfachen Beispiel die Handhabung des Web-Frameworks Flask zu demonstrieren.
Von Python zu HTML
Die Grundidee ist zuerst einmal, HTML-Codeschnipsel zu generieren, die dann jeder nach eigenen Bedürfnissen in größerem Kontext verwenden kann.
Pandas nach HTML
Wer in Python mit Daten jongliert, tut das normalerweise mit DataFrame-Objekten des Pandas-Frameworks. Ihren Inhalt kann man leicht als Tabelle, also <table> darstellen. Die fast schon triviale Methode zur Konvertierung ist:
table_html = df.to_html() # df ist ein DataFrame
Das wichtigste Argument (für unsere Zwecke) der Methode heißt classes, dem man eine Liste der CSS-Klassen der zu generierenden <table> als Strings übergibt:
table_html = df.to_html(['table', 'table-striped'])
Es gibt allerdings einige Stolperfallen:
- Zellen mit langen Texten werden standardmäßig gekürzt:
Verantwortlich ist die Pandas-Option display.max_colwidth, die sich aber deaktivieren lässt über:pandas.set_option('display.max_colwidth', -1)
In diesem Zuge sei auch die Option display.float_format erwähnt, über die man das Ausgabeformat der Fließkommazahlen definieren kann.
- HTML-Tags werden in Entities umgewandelt. D.h. wenn ein Textfeld HTML-Tags (beispielsweise Hyperlinks) beinhaltet, werden diese im fertigen HTML zu darstellbaren Zeichen umgewandelt, statt zu HTML-Elementen zu werden. Das ist einerseits eine gute Sicherheitsmaßnahme, um die Darstellbarkeit der Tabelle bei jeglichen Textinhalten zu gewährleisten – andererseits erschwert es zusätzliche Funktionalität – wie Hyperlinks – ungemein. Für die nachträgliche Manipulation von HTML muss man dann andere Tools heranziehen, wie Beispielsweise das in meinem letzten Blogbeitrag kurz vorgestellte Beautiful Soup, mit dem HTML nicht nur hierarchisch durchsucht, sondern auch modifiziert werden kann.
Pandas nach Excel
Der Vollständigkeit halber sei auch noch die Konvertierung in ein gängiges Spreadsheet-Format erwähnt, da fast jeder Kollege, der tabellarische Daten will, diese für seine weiteren Zwecke nicht manuell kopieren möchte – und sich erst recht nicht mit Encoding-Problemen herumschlagen. Ich empfehle dabei, einen pandas.ExcelWriter zu verwenden, da er erlaubt, unkompliziert mehrere Tabellen in jeweils eigene Sheets zu schreiben:
outfile = '/tmp/two_dataframes.xlsx' excel_writer = pandas.ExcelWriter(outfile) df_a.to_excel(excel_writer, sheet_name='data_a') df_b.to_excel(excel_writer, sheet_name='data_b') excel_writer.close()
Die Kernfunktion ist also pandas.DataFrame.to_excel. Ein häufig benutztes Argument ist index=False, mit dem der Index des DataFrames in der Ausgabe nicht übernommen wird.
Matplotlib nach HTML
Die gängigste Plotting-Bibliothek in Python ist Matplotlib, die auch in der Grundkonfiguration von Pandas verwendet wird. Zudem gibt es Bibliotheken, die darauf aufsetzen (z.B. Seaborn), deren grafische Ausgaben somit echte Matplotlib-Objekte sind und genauso konvertiert werden können. Die interaktive Konvertierung ist einfach, jedoch zeigt die automatisierte Konvertierung zu eingebettetem HTML einige Tücken:
import io import matplotlib.pyplot as plt def axes_to_html(axes, **kwargs): """ Konvertiere matplotlib-Plot zu HTML. :param matplotlib.axes.Axes axes: Matplotlib figure oder axes Instanz - oder None im Falle des letzten erzeugten Plots. :param dict kwargs: Konversions-Argumenten. Siehe matplotlib.pyplot.savefig :return: div mit Plot als base64-codiertes Bild :rtype: str """ if axes: if not hasattr(axes, 'number'): axes = axes.get_figure() plt.figure(axes.number) output_stream = io.BytesIO() if 'dpi' not in kwargs: kwargs['dpi'] = 75 # Mein Standardwert plt.savefig(output_stream, **kwargs) data_uri = base64.b64encode(output_stream.getbuffer()).decode('utf-8').replace('n', '') plt_plot_html = '<div><img src="data:image/png;base64,%s" /></div>' % data_uri return plt_plot_html
Die eigentliche Methode plt.savefig operiert nur auf den aktiven (i.d.R. den letzten gezeichneten) Plot. Falls man einen bestimmten Plot konvertieren will, wird er hier in der Variable axes referenziert. Die Rückgabe von Plotting-Methoden kann aber in Pandas, Matplotlib und Seaborn variieren, weshalb in bestimmten Fällen die get_figure-Methode genutzt werden muss, bevor mit plt.figure der Plot zum Aktiven gemacht wird. Die Ausgabe von savefig wird als Bytestream gespeichert und zur Verwendung in HTML nach base64 konvertiert. Dann nur noch Tags drumherum und wir haben es geschafft. Uff!
Bokeh nach HTML
Noch nicht so etabliert wie Matplotlib, aber mit einigen sehr nützlichen Features ausgestattet, empfiehlt sich das Framework Bokeh für unsere Dienste. Es ist für den Einsatz im Browser konzipiert und kreiert dazu aus Python-Objekten interaktive(!) Visualisierungen auf JavaScript-Basis.
Man bindet daher Skripte, Objekte und deren Daten ein, die in unterschiedlichen Teilen des HTML-Quelltextes stehen, und im Falle mehrerer Graphen auch überlappen – anders als bei Matplotlib, wo nur ein statisches Bild exportiert wird. Folgende Funktion stemmt die Konvertierung:
from bokeh.resources import CDN, INLINE from bokeh.embed import components def figure_to_html(figure_list, bokeh_inline=False): """ Konvertiere Bokeh-Plot zu HTML. :param list figure_list: bokeh.plotting.Figure in iterierbarem Container. :param bool bokeh_inline: Setze den JS- und CSS-Quelltext direkt in den Header, statt zu verlinken. :return: (Header-Elemente, Divs) *Header-Elemente* ist eine Liste von Strings, die je einer Zeile im Header entsprechen. *Divs* ist eine Liste von Strings, die divs mit den Plots beinhalten. :rtype: tuple """ bokeh_resources = INLINE if bokeh_inline else CDN header_elements = [bokeh_resources.render_js(), bokeh_resources.render_css()] bokeh_script, bokeh_divs = components(figure_list) header_elements.append(bokeh_script) return header_elements, bokeh_divs
Zunächst muss man entscheiden, ob man die allgemeinen Bokeh-Bibliotheken im Header verlinkt, oder mit inline=True direkt einbindet, was die fertige Seite unabhängig von einer Internetverbindung macht, aber sehr viel Code erzeugt. Der Kern der Konvertierung ist die components-Methode, die zum einen zusätzlichen JavaScript-Code je nach Bedarf erzeugt und zusammen mit den Datenpunkten des Schaubildes als weitere Komponenten des Headers (Variable bokeh_script) einbettet, und zum anderen die HTML-divs erzeugt, die an der finalen Stelle im Layout eingebaut werden können (Variable bokeh_divs).
Flask
Flask bezeichnet sich selbst als Microframework und eignet sich hervorragend für minimalistische Webanwendungen, da das Beantworten von HTTP-Requests mit sehr wenigen Befehlen realisiert wird:
from flask import Flask app = Flask('meine_flask_app') @app.route("/") def hello(): return "Hello World!" app.run()
Das Flask-Objekt in der app-Variable ist das zentrale Element einer Flask-Anwendung. Der decorator app.route legt den Endpunkt fest, also den Pfad in der URL, die man im Browser ansteuert – und der Browser erhält den Rückgabewert der dekorierten Funktion. Über app.route lässt sich zudem die Methode (GET als default oder POST) festlegen, sowie Platzhalter im Pfad definieren.
Jinja2
Flask setzt beim Erstellen des HTML-Inhaltes auf die Template-Engine Jinja2. Ihr Funktionsumfang sprengt allerdings den Rahmen dieses Artikels, und wir nutzen nur ihre Template-Klasse mit den Basisfunktionen der Textersetzung, for-Schleifen und if-else-Bedingungen. Ein Beispiel:
from jinja2 import Template tpl = Template(''' <!DOCTYPE html> <html> <head> <title>{{title}}</title> </head> <body> <h1>{{list_head}}</h1> {% if items %} {% for item in items %} <li>{{item}}</li> {% endfor %} {% else %} <p>nix...</p> {% endif %} </body> </html> ''')
>>> print(tpl.render(title='Ich mag...', list_head='Obst', items=['Banane', 'Apfel', 'Birne'])) <!DOCTYPE html> <html> <head> <title>Ich mag...</title> </head> <body> <h1>Obst</h1> <li>Banane</li> <li>Apfel</li> <li>Birne</li> </body> </html>
>>> print(tpl.render(title='Ich mag...', list_head='Obst')) <!DOCTYPE html> <html> <head> <title>Ich mag...</title> </head> <body> <h1>Obst</h1> <p>nix...</p> </body> </html>
Der Ausdruck {{Variable}} ist also ein Platzhalter für Werte, die mit der render-Methode übergeben werden. Der Ausdruck {% Ausdruck %} steht für Python-artige Ausdrucke und Funktionalität. Damit hat man bereits das Rüstzeug für simple HTML-Seiten. Nicht unerwähnt bleiben sollte die autoescape-Funktionalität. Es handelt sich um ein Sicherheitsfeature, um das Einschleusen von HTML-Ausdrücken in Variablen zu unterbinden. Meine Beispiele funktionieren aktuell ohne dies zu berücksichtigen, jedoch kann sich dies in Zukunft ändern und „{% autoescape false %}„-Blöcke notwendig machen.
BeispielAnalyse
Als Beispiel betreiben wir die recht sinnfreie Analyse des Nahrungsmittelanteils an Spenden von Lobbyisten an Staatsbedienstete des Bundesstaates Oklahoma in den Jahren 2006 bis 2008:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 |
import datetime import requests from jinja2 import Template from flask import Flask, request import pandas as pd from bokeh.charts import Donut from bokeh.resources import CDN, INLINE from bokeh.embed import components def figure_to_html(figure_list, bokeh_inline=False): """ Konvertiere Bokeh-Plot zu HTML. :param list figure_list: bokeh.plotting.Figure in iterierbarem Container. :param bool bokeh_inline: Setze den JS- und CSS-Quelltext direkt in den Header, statt zu verlinken. :return: (Header-Elemente, Divs) *Header-Elemente* ist eine Liste von Strings, die je einer Zeile im Header entsprechen. *Divs* ist eine Liste von Strings, die divs mit den Plots beinhalten. :rtype: tuple """ bokeh_resources = INLINE if bokeh_inline else CDN header_elements = [bokeh_resources.render_js(), bokeh_resources.render_css()] bokeh_script, bokeh_divs = components(figure_list) header_elements.append(bokeh_script) return header_elements, bokeh_divs class LobbyistGiftHandler: """ Klasse, die die Funnktionalitaet zum verwendeten Datensatz buendelt. Die Daten werden nur einmalig bei geladen und sind als data property zugaenglich. Info zum Datensatz: https://opendata.socrata.com/Business/Oklahoma-Lobbyist-Gifts-2006-2008/4cie-i6ie """ data_url = 'https://opendata.socrata.com/resource/4cie-i6ie.json' food_names = ['meal', 'dinner', 'lunch', 'breakfast', 'food'] _data = None _tpl_general = Template(''' <!DOCTYPE html> <html> <head> <title>{{title}}</title> {% if header_items %} {% for header_item in header_items %} {{header_item}} {% endfor %} {% endif %} </head> <body> <h1>{{body_title}}</h1> {{body}} </body> </html>''') _tpl_main_body = Template(''' <h2>Total Overview</h2> <table> <tr> <th>Month</th><th>Amount</th><th>#Donations</th><th>Food Ratio</th> </tr> {% for row in table %} <tr> <td>{{row.month_link}}</td> <td align="right">{{'%.02f' % row.amount}}</td> <td align="right">{{row.n_donations}}</td> <td align="right">{{'%.01f%%' % (100 * row.food_ratio)}}</td> </tr> {% endfor %} </table> ''') _tpl_month_body = Template(''' <h2>Pie Chart</h2> {{pie_chart}} <h2>Tabular Overview</h2> {{table}} ''') @property def data(self): """ Die Daten. """ if self._data is None: data = pd.DataFrame(requests.get(self.data_url).json()) # Floats wurden als Strings gespeichert, trotz JSON... data.amount = data.amount.astype(float) data.nature_of_gift = data.nature_of_gift.str.lower() # identifiziere Essen data['is_food'] = False for food in self.food_names: data.is_food |= data.nature_of_gift.str.count(food) # bestimme Monat der Spende data['t_month'] = data.transaction_date.apply( lambda ts: datetime.date( datetime.datetime.fromtimestamp(ts).year, datetime.datetime.fromtimestamp(ts).month, 1 ) ) self._data = data return self._data @staticmethod def _get_position_share(data): """ Baue die nach Position aggregierten Daten. :param pd.DataFrame data: gefilterte Rohdaten :return: Aggregation nach Position :rtype: pd.DataFrame """ grouped = data.groupby('position') data_out = grouped.size().to_frame('n_donations') data_out = data_out.join(grouped['amount'].sum()) data_out = data_out.join(grouped.apply( lambda df: df[df.is_food].amount.sum() / df.amount.sum()).to_frame('food_ratio')) return data_out.sort_values('amount', ascending=False).reset_index() @staticmethod def _get_donut(position_stats): """ Erstelle Pie-Chart als Bokeh-Figure. :param pd.DataFrame position_stats: Nach Position aggregierte Daten. :return: Pie-Chart :rtype: bokeh.plotting.Figure """ d_plot = position_stats.assign(fraction=lambda x: x.amount / x.amount.sum()) d_plot.loc[d_plot.fraction < 0.03, 'position'] = 'other' d_plot = d_plot.groupby('position').amount.sum().sort_values(ascending=False) return Donut(d_plot) @staticmethod def _get_overview(data): """ Baue die nach Monat aggregierten Daten. :param pd.DataFrame data: Rohdaten :return: Aggregation nach Monat :rtype: pd.DataFrame """ grouped = data.groupby('t_month') data_out = grouped.size().to_frame('n_donations') data_out = data_out.join(grouped['amount'].sum()) data_out = data_out.join(grouped.apply( lambda df: df[df.is_food].amount.sum() / df.amount.sum()).to_frame('food_ratio')) return data_out.sort_index() def main_page(self): """ Baut die Hauptseite mit Links zu den verfuegbaren Unterseiten. :return: HTML :rtype: str """ overview_stats = self._get_overview(self.data) # baue Link-Spalte overview_stats['month_link'] = [ '<a href="/monthly?month={0}&year={1}">{1} {0:02d}</a>'.format(x.month, x.year) for x in overview_stats.index] # baue Liste aus namedtuples fuer Iteration im Template table = [row for row in overview_stats.itertuples()] # fuelle Templates body = self._tpl_main_body.render(table=table) html = self._tpl_general.render(title='All Months Overview', body_title='Donations by Month', body=body) return html def month_page(self, year, month): """ Baut die Unterseite fuer den entsprechenden Monat. :param int year: Bezugsjahr :param int month: Bezugsmonat :return: HTML :rtype: str """ # Filtere nach Monat date_filter = datetime.date(year, month, 1) data_of_month = self.data.query('t_month == @date_filter') if not len(data_of_month): return 'no data for requested month.', 503 # Statuscode fuer "service unavailable" # kreiere Statistik position_stats = self._get_position_share(data_of_month) # kreiere Bokeh-Plot bokeh_pie = self._get_donut(position_stats) header_elements, bokeh_divs = figure_to_html([bokeh_pie]) # Fuelle Templates body = self._tpl_month_body.render(pie_chart=bokeh_divs[0], table=position_stats.to_html(index=False)) html = self._tpl_general.render(title='Position Statistics %i %i' % (year, month), header_items=header_elements, body_title='Monthly Position Statistics', body=body) return html handler = LobbyistGiftHandler() app = Flask(__name__) @app.route('/') def main_page(): """ Hauptseite. """ return handler.main_page() @app.route('/monthly') def month_page(): """ Unterseite fuer einen bestimmten Monat. """ # lade Jahr und Monat aus dem Query der Adresse month = int(request.args.get('month', 1)) year = int(request.args.get('year', 2006)) return handler.month_page(year, month) app.run() |
Das Programm beginnt mit der bereits oben vorgestellten figure_to_html-Funktion zur Konvertierung von Bokeh-Plots (Z. 11). Die Entscheidung, den Großteil der Funktionalität in eine Klasse zu packen, ist nicht zwingend und hat hier neben der besseren Erweiterbarkeit die Verwendung von LobbyistGiftHandler.data als property für die einmalig zu ladenden Rohdaten zum Hauptgrund (Z. 76).
Die Flask-App wird erst am Ende definiert (Z. 191). Für die Endpunkte „/“ und „/monthly“ wird jeweils eine Funktion definiert, die Methoden der handler-Klasse aufruft. In zweitem Fall werden die query-Argumente zuerst noch als Integer geparsed, wofür die flask.request-Methode benutzt wird. Bei GET-Anfragen finden sich die Daten in request.args, und bei POST in request.form. Die Funktionalität wird angestoßen, wenn Flask eine entsprechende Anfrage durch den Browser erhält. Die aufgerufenen Methoden LobbyistGiftHandler.main_page und LobbyistGiftHandler.month_page steuern die Datenprozessierung und die HTML-Generierung. Die Datenprozessierung findet über Pandas in den Helfermethoden (Z. 99-140) statt. Erst danach wird die HTML-Erzeugung vorbereitet:
- main_page: Dargestellt wird das Volumen, die Anzahl und der Nahrungsmittelanteil aggregiert nach Monat. Die Monatsspalte fungiert dabei gleichzeitig als Hyperlink zur entsprechenden Unterseite. Dazu wird in Z. 150 eine Hyperlink-Spalte gebaut. In Z. 154 wird das Dataframe in eine Liste von Reihen als Namedtuple umgewandelt, da die to_html-Methode den Hyperlink zerstören würde, und Listen von Namedtuples in Jinja2 leicht zu verarbeiten sind. Das Template für den HTML-body wird damit in Z. 156 gefüllt, gefolgt vom allgemeinen HTML-Template.
- month_page: Die Seite stellt im gewählten Monat grafisch die Verteilung der Beträge auf verschiedene Positionen dar, und yeigt tabellarisch die nach Position aggregierten Spenden und Nahrungsmittelanteile. In Z. 174 wird zunächst nach dem Monat gefiltert und in Z. 176 die Aggregierung durchgeführt. Daraus wird in Z. 178 ein Bokeh-Pie-Chart generiert, dessen HTML-Elemente in der Folgezeile ergeugt werden. Die Methode füllt ebenfalls erst das body– dann das allgemeine Template.
Die fertigen Seiten sehen etwa so aus:
Die Interaktivität des Pie-Charts ist hier natürlich nur angedeutet. Mit obigem Code kann sie aber ganz einfach selbst erzeugt werden.
Fazit
Der durch Flask erzeugte Overhead für so ein Projekt ist sehr überschaubar. Die Analyse selbst erfordert weiterhin den Löwenanteil der Arbeit, der Rest ist etwas Navigationslogik, und Templates, die in vielen Fällen wiederverwertbar sind, wodurch sich der Zeitaufwand von der Analyse bis zum Deployment der Webseite immer weiter verringert. Mit den hier gezeigten Techniken sind Sie auf jeden Fall für die ersten Gehversuche gerüstet.
Für den weiteren Weg lohnt sich ein Blick in weitere Bibliotheken oder Techniken, die sich mit Flask kombinieren lassen. Für besseres Layout wäre das beispielsweise Bootstrap, zur Anbindung an bestehende Webserver (z.B. mit HTTPS-Barriere) gibt es uWSGI, und für extrem einfache REST-APIs empfiehlt sich Flask-RESTful. Haben Sie weitere Ideen oder auch Fragen, freue ich mich auf Ihre Kommentare.