Merge branch 'main' of ssh://git.jamesravey.me:222/ravenscroftj/brainsteam.co.uk into main
continuous-integration/drone/push Build is passing Details

This commit is contained in:
James Ravenscroft 2022-01-18 20:06:12 +00:00
commit 18c8bbf051
20 changed files with 2506 additions and 4 deletions

View File

@ -1,8 +1,6 @@
kind: pipeline kind: pipeline
name: update_website name: update_website
when:
branch:
- main
steps: steps:
- name: fetch_webmentions - name: fetch_webmentions
image: python:3.7 image: python:3.7
@ -22,7 +20,9 @@ steps:
- name: store_webmentions - name: store_webmentions
image: appleboy/drone-git-push image: appleboy/drone-git-push
when:
branch:
- main
settings: settings:
remote_name: origin remote_name: origin
branch: main branch: main

View File

@ -7,6 +7,8 @@ disqusShortname = "brainsteam"
copyright = "© James Ravenscroft" copyright = "© James Ravenscroft"
pygmentsstyle = "vs" pygmentsstyle = "vs"
pygmentscodefences = true pygmentscodefences = true
pygmentscodefencesguesssyntax = true pygmentscodefencesguesssyntax = true
@ -26,6 +28,8 @@ unsafe= true
avatar = "/images/avatar_small.png" avatar = "/images/avatar_small.png"
favicon = "/images/favicon.png"
mainSections = ["post", "note"] mainSections = ["post", "note"]
indieWebSections = ["note","reply","like","repost","bookmark"] indieWebSections = ["note","reply","like","repost","bookmark"]

View File

@ -0,0 +1,12 @@
---
bookmark-of: https://firepad.io/
date: '2022-01-16T15:41:43.969974'
tags:
- open source
- Distributed
title: Firepad - An open source collaborative code and text editor
type: bookmark
url: /bookmarks/2022/01/16/firepad-an-open-source-collaborative-code-and-text-editor1642365703
---

View File

@ -0,0 +1,12 @@
---
bookmark-of: https://blog.ipfs.io/2021-11-03-understanding-fundamentals-of-ipfs/
date: '2022-01-16T15:36:20.515274'
tags:
- Distributed
title: Understanding the Three Fundamental Principles of How IPFS Works | IPFS Blog
& News
type: bookmark
url: /bookmarks/2022/01/16/understanding-the-three-fundamental-principles-of-how-ipfs-works-ipfs-blog-news1642365380
---

View File

@ -0,0 +1,13 @@
---
bookmark-of: https://copyq.readthedocs.io/en/latest/
date: '2022-01-17T14:31:06.263731'
tags:
- open source
- productivity
title: "Welcome to CopyQ\u2019s documentation! \u2014 CopyQ documentation"
type: bookmark
url: /bookmarks/2022/01/17/welcome-to-copyqs-documentation-copyq-documentation1642447866
---
Recommended productivity tool from a discussion on lemmy

View File

@ -0,0 +1,11 @@
---
date: '2022-01-17T14:26:57.157661'
like-of: https://colinraffel.com/blog/a-call-to-build-models-like-we-build-open-source-software.html#anexamplefuture
tags:
- machine-learning
title: A Call to Build Models Like We Build Open-Source Software
type: like
url: /likes/2022/01/17/a-call-to-build-models-like-we-build-open-source-software1642447617
---

View File

@ -0,0 +1,21 @@
---
date: '2022-01-17T06:02:00.032506'
mp-syndicate-to:
- https://brid.gy/publish/mastodon
- https://brid.gy/publish/twitter
photo:
- /media/2022/01/17/1642417320_0.jpg
tags:
- personal
type: note
url: /notes/2022/01/17/1642417320
---
<img src="/media/2022/01/17/1642417320_0.jpg" class="u-photo" />
My cat likes the sunshine as much as I do in dreary January although his idea of helping with my work could use some adjustment
<a href="https://brid.gy/publish/mastodon"></a>
<a href="https://brid.gy/publish/twitter"></a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@ -0,0 +1 @@
<mxfile host="Electron" modified="2022-01-13T17:55:19.083Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/16.1.2 Chrome/96.0.4664.55 Electron/16.0.5 Safari/537.36" etag="vKbJ9cuR-e8dDawoIGwq" version="16.1.2" type="device"><diagram id="trVi9swQPnjRLx_srgvz" name="Page-1">7Vnbbts4EP0aA92HGhJ1sfxoO0l30W1rbICmfVrQEiOxpUQtRfnSr+9QoizrYsfbWkmQNoBj8fB+5sxwKI+sRbx9I3AaveMBYSNkBNuRdTVCCBkugi+F7ErERJZXIqGggcZq4JZ+Ixo0NJrTgGSNhpJzJmnaBH2eJMSXDQwLwTfNZvecNWdNcUg6wK2PWRe9o4GMStRDkxr/k9AwqmY23WlZE+Oqsd5JFuGAbw4g63pkLQTnsnyKtwvCFHsVL2W/myO1+4UJkshzOtx9XC/97ONb+8M/XnrnCf7+E3/tlKOsMcv1hvVi5a5igARAiC5yISMe8gSz6xqdC54nAVHTGFCq2/zNeQqgCeAXIuVOWxfnkgMUyZjp2nJONdHRvWko47nwyYkNVRrBIiTyRDt7bwHQLuExkWIH/QRhWNJ1cx1Yayjct6tphgfN9P9g3exh3f0vV0qY/1WsAjOmlsP4GniF7URErcOXNAnV4Mkei/makjE8zJS6cSJxJqmvyCPinosYJ75qttqNx+N6ko6RJdnKpk0yKfhXsuCMC0ASnihL31PGWhBmNEyg6IOpCODzNRGwAsxmuiKmQVDIZBNRSW5TXNhvA/GiIx1NCwxAtqeV0LVc1UF7WxVvPK3wTe28pqvbRAeOaxkD2Rq9OBezz3Qx9KQ+ZvU52ePzDuyK3Sfdvyh8VoWxUxWvtoeVV7uGKzyuvSY/aS/ddckpLHHvkXvX0i5pmS1XKxeme9VWnwmBdwfNUtUgOzGP3ZpHe14tonLEWlL7Pf64yuyOyJYQvXKxwhKCZFtvFLKUpjxoXOYfNIa4fuMzCgaGPd28IVj8ayJvC59xqoL+T0dGZLRC48TohMa+yOgOFhnP8NADqvqOD8ip0oK+jC/yFYHdz1fY/xoWbvshl4yqM6pkOuOzJCzGNZ3WUTZCllv8XeYMst0W006X6WkP0yYajGrUoXrOgCiA5nwL/3Xm/lKzAttrWgR552l/uKzAelj7lbj9XLDdXIC5VPjuJezAQjV35rAqd9oqt7ucoh5OUTv+X45U91kc+Rc8utHZZ/cRWz1OroW6x+CRCw3ATIEreAiLm4gzfze7fTtyrqoqWEFdi1wcK4Enq0x9/b4HnX3ae8/tImR1z6Cn8M4jCfmJdHzija0pmpqm66GJN3XcqhZSPQrcKGtfOmev3i8N7/g/lGvbzslc+8H2IM+WngbIzSsOf0elZxuV9nfBp3s943VEUtii3wLn0n5gqXueSB2M4JjUZT2wecogHdNdgH+rdQdEPTmbafTwbw92KvQlwqXLKaYaVqj8Q1W8zgpOlV+ZVro9dJ7SSZevUp5RFQL/UB6u+hvGeALmhoUay1cJCXFZq17H1LPCLsqJK19/mUpw7YfvqI+shN4kcjAlOE6/EmznV1NC+yw3nlwJ3VfmLzgmtz1xyJgMxfoHxzLPqn+3ta6/Aw==</diagram></mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1 @@
<mxfile host="Electron" modified="2022-01-13T17:56:50.563Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/16.1.2 Chrome/96.0.4664.55 Electron/16.0.5 Safari/537.36" etag="s7fXGPMBWlyq6OiZMqEq" version="16.1.2" type="device"><diagram id="trVi9swQPnjRLx_srgvz" name="Page-1">7Vnbcts2EP0azaQP0ZDgRdSjJF+SSdNo6pk4eepAJEyiAQkWhG79+ixIULxaZhzJdt14RhZxsCSAs2cXC2pkLeLdtcBp9JEHhI2QEexG1sUIIWS4CL4Usi8QE1legYSCBhqrgBv6L9GgodE1DUjWMJScM0nTJujzJCG+bGBYCL5tmt1x1hw1xSHpADc+Zl30lgYyKlAPTSr8HaFhVI5sutOiJ8alsV5JFuGAb2uQdTmyFoJzWVzFuwVhir2Sl+K+q3t6DxMTJJFDbrj9vFn62ecP9qc/vfTWE/yPL/ytUzxlg9laL1hPVu5LBkgAhOgmFzLiIU8wu6zQueDrJCBqGANalc3vnKcAmgD+TaTca+/iteQARTJmure7FL26jK+FT47Mv5QEFiGRR+zswk6tpTaAJuqa8JhIsQcDQRiWdNN0PtYaCg92Fc1woZn+AdbNHtbdf9ZKCfP3+SwwY2o6jG+AV1hfRNQ8fEmTUD08OWAx31AyhouZUjdOJM4k9RWbRNxxEePEV2ar/Xg8rgbpOFmSnWz6JJOCfyMLzrgAJOGJ8vQdZawFYUbDBJo++I4APt8QATPAbKY7YhoEuUy2EZXkJsW5Q7eQLzrSuVcH6plkd9RzZa+OtjLfeFrh2yp4TVfbRLXAtYwz+Rr910PMHhhi6GXFmNUXZE/PO9At9l/0/Xnjq2qMnbJ5sat3XuwfCoXT+Wtyan/pW5ecwpwPEXkILR2SltkKtWKm+q7K6zMh8L5mliqD7Mg4dmscHXmViIonVpI6rPHxKrMfFhmF0qSpCRoXRQeNIZlf+YyCm2EhV9cEi79M5O3gM05Vpv/pdIiMVj6cGJ182JcO3bOlwwFhWaOqb8+AQirN6cv4Yr0isPr5CvvfwjxWP60lo2pjKpjO+CwJ8+eaTmv/GiHLzf9Os/HYbotpp8v0tIdpE52NatShes6AKIDmfAf/dbn+WksB22t6BHnDtH++UsB6WPuluP21YPu5AHepJN5LWM1DFXfmeVXutFVudzlFPZyidtI/Hanui9jnH79fo8EbtvmiCizUt/f1nmIAZgpcwUWYHz+c+cfZzYeRc1F2wQyqXuTiWAk8WWXq69fhZ/Bu772004/V3YOeIzrvqcKP1OATb2xN0dQ0XQ9NvKnjlr1LIihwo7z9k4V6WYA/Q+A/qsC2naMF9oP2IM+Wns5QkJekDnu30p9qfjhf/cpJw3PS4fj3fG9kvI5Ecl/0e2Ao7TVP3fFE6lQEm6Ru6webxxzScd0J+LdaJ0DUU7GZRg//9tn2hL4yuAgrxVTDC2V8qI63Wc6piivTSnf14CkCcfkm5RlVCfA3FcXqfsMYT8DdMFFj+SYhIS561RuYalRYRTFwGc+vUwluO38bz66E3hLybEpwnH4l2M7/XAl97yqeWAndt+SvOCe79tPlZGhWvzEWVVb1U611+R0=</diagram></mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -0,0 +1,436 @@
---
title: Painless Explainability for NLP/Text Models with LIME and ELI5
type: post
description: An introduction to LIME ML model explainability in the context of NLP usage and how to use ELI5 library - a painless way to use LIME local explainability for almost any model.
resources:
- name: feature
src: images/scrabble.jpg
date: 2022-01-17T13:01:11+00:00
url: /2022/01/17/painless-explainability-for-text-models-with-eli5
toc: true
tags:
- machine-learning
- work
- explainability
---
## Introduction
Explainability of machine learning models is a hot topic right now - particularly in deep learning where models are that bit harder to reason about and understand. These models are often called 'black boxes' because you put something in, you get something out and you don't really know how that outcome was achieved. The ability to explain machine learning model's decisions in terms of the features passed in is both useful from a debugging standpoint (identifying features with weird weights) and with legislation like [GDPR's Right to an Explanation](https://www.privacy-regulation.eu/en/r71.htm) it is becoming important in a commercial setting to be able to explain why models behave a certain way.
In this post I will give a simplified overview of how LIME works (I may take some small technical liberties and manufacture some contrived examples to demonstrate some of these mechanisms and phenomena - apologies) and then I'll give a brief explanation of how LIME can be applied to a sci-kit learn SVM-based sentiment model and then a huggingface/torch sentiment model.
{{<figure src="images/scrabble.jpg" caption="understanding individual contributions of words is useful when working with NLP Classification Models">}}
## Understanding LIME
Lime stands for **L**ocal, **I**nterpretable **M**odel-agnostic **E**xplanations and is a technique proposed by [Ribeiro et al.](https://arxiv.org/abs/1602.04938) in 2016. The basic premise is that for a given input example (in an image classifier we're talking 1 image, in a text classifier we're talking 1 unit of text e.g. a paragraph or a sentence, in a numerical model trained on tabular data we're talking 1 row from that table), LIME can approximate how much of an effect each of the features extracted from the input have on the final output (i.e. How important are a cluster of pixels in an image?, How important are specific words/phrases in a sentence?, How important is each column in that row of numbers?).
For a given example both contributing and negating features are highlighted (reasons for and against that decision).
{{<figure src="images/figure1.png" caption="Figure 1 from the [Ribeiro et al](https://arxiv.org/abs/1602.04938) paper giving an overview of how LIME works">}}
### Local
The local aspect of LIME is described in [the paper](https://arxiv.org/abs/1602.04938):
> ...Although it is often impossible for an explanation to be completely faithful unless it is the complete description of the model itself, for an explanation to be meaningful it must at least be locally faithful, i.e. it must correspond to how the model behaves in the vicinity of the instance being predicted...
>
This is a really important constraint of LIME: it offers excellent example-specific explanations that work well for pockets of similar data points but these explanations can't necessarily be generalised for the whole of the model under examination. The authors of the paper also attempt to illustrate this limitation in a diagram:
{{<figure src="images/figure3.png" caption="Figure 3 from the [Ribeiro et al](https://arxiv.org/abs/1602.04938) attempts to illustrate how LIME can offer explanations within a local neighbourhood of data samples">}}
#### To Spam or not to spam: that is the question
This is especially important in tasks that are highly context dependent (like text classification). Here's a contrived example of a spam detection use case. Take the words "7 million usd" as in:
>Sir,
>
>I am a wealthy widow and if you help me I will pay you 7 million usd
>
>Best Regards
and also
>Kevin,
>
>the new term sheet from the investors is in, they're offering 7 million usd for 5% equity,
>
> Brian Smith <br/>
> Head of Mergers & Acquisitions
In the first example, the words "7 million usd" contribute to the suspicion that this is a scam in the presence of "wealthy widow" and "help me". In the second example the words "7 million usd" aren't as important, they're words that you'd probably expect in a legitimate email about an investment opportunity from your colleague in Mergers.
The point I'm trying to make is that it's very difficult to come up with good general rules about which words are important without any context (and indeed if you can do that then you probably don't need machine learning, you can just build a rule-based system that checks for the presence or absense of words on a list). The overall decision function of "spam or not spam" is much more complicated than "these words are good and these words are bad" but for a certain set of "spammy" examples we can certainly say which words are more spammy and which words are less spammy. This is analogous to the concepts at play in LIME too.
Therefore when we're using LIME, we should avoid saying things like "The model seems to consider the words 'million' and 'usd' spammy" and we should say things like "in cases similar to the widow email, it looks like the words 'million' and 'usd' contributed to the decision that this email was spam in the absense of any other redeeming words".
### Interpretable
Some machine learning models like [linear models](https://scikit-learn.org/stable/modules/linear_model.html) and [Decision Trees](https://scikit-learn.org/stable/modules/tree.html) are inherently interpretable through being able to measure parameter coefficients (how big the weight of the feature is when calculating the decision boundary line) in the case of the former and how early on a feature appears in a decision tree (since decision trees use [information gain](https://en.wikipedia.org/wiki/Information_gain_in_decision_trees) to put features that tell us most about the final classification/decision near the top of the tree so that they impact more data points) in the case of the latter.
LIME exploits these explainable models in order to explain the local context around a given input example. We perturb (slightly change) the input example and use the black-box model under analysis to make predictions. As words are added or removed from the input, the output from the black box model changes slightly (in the [contrived again] example below, removing the word 'love' from the movie review reduces the probability that the review is positive.)
{{<figure src="images/perturbation.png" caption="LIME perturbs input examples by changing words around in order to understand the individual contributions of words to an outcome">}}
These perturbed inputs and the outputs from the 'black box' model that we're analysing outputs are then used as a training set to train the local, interpretable model.
For text models, LIME uses [Bag-of-Words](https://en.wikipedia.org/wiki/Bag-of-words_model) (BoW) representations of the perturbed input as the features for the local model.
We can then use the interpretable information (parameter coefficients/feature position in decision tree) for the local model to approximately interpret the effect that the different words have on the bigger model since each word in the local BoW vocabulary will have an associated coefficient.
### Model-Agnostic
LIME's model agnosticism is one of its most useful attributes. As long as you know how to encode the input data and your model has the ability to provide probabality distributions over its outputs, you can provide local explanations for any type of model. This is because the explanation comes from the local model and the BoW features therein rather than the black box model.
In the section below I've provided some examples of how to use ELI5 with some different types of models.
### Explanation
Explanations that are produced by LIME for NLP models are expressed in terms of which words/phrases were considered as the biggest contributing factors towards a class decision by the model.
If you look at the results in Jupyter you'll get red and green highlights over the text input showing the degree to which each word contributed (green) or reduced (red) the likelihood that the input example is from the class under the microscope. In the example below you can see that kidney stones and medication are keywords that the model has learned can be used to classify examples in this neighbourhood (remember these explanations don't apply globally) as medical and that the presence of these words detracts from the likelihood that the email is about religion or graphic design.
{{<figure src="images/explanation_svm.png" caption="An example explanation from LIME">}}
The `<BIAS>` contribution is the model's underlying bias towards or against a particular class - again ***within this neighbourhood**. The most intuitive way to think about this parameter is that it describes the model's perception that other examples, similar to this one, belong to the given class. The bias is usually a much smaller contributing factor than the actual features as we see in the example above.
We can also inspect the weights/feature importances that the model has generated ***for the current local neighbourhood*** and see, for each class, what words or phrases the model thinks are predictive of a particular class.
{{<figure src="images/weights.png" caption="Feature importances example">}}
This table can also be useful as it can highlight surprising/incorrect results like that "to be" or "do anything" might signal a post about atheism. It's always worth having a look and if you see anything weird then also [check whether the model is trustworthy](#checking-whether-the-explanation-is-trustworthy) or whether your black-box model might be doing something strange.
## Usage Examples
### Requirements and Setup
In order to get any of the examples below running you will need a relatively recent version of Python 3 and the [eli5](https://eli5.readthedocs.io/en/latest/autodocs/lime.html#eli5.lime.lime.TextExplainer) library installed too. You will probably want to run the example code in a [Jupyter Notebook](https://jupyter.org/) so that you can see the pretty graphical explanations.
If you're not sure about which version of Python to install, you might want to have a quick look at [my opinionated guide to Python environment setup](/2021/04/01/opinionated-guide-to-virtualenvs/).
All of these examples will work fine on machines without GPUs although the [transformer model](#eli5-and-transformershuggingface) is a little slow running on CPU (it takes about 60 seconds to run on my 2020 Dell XPS w/ i7, 16GB RAM).
### ELI5 and Sci-kit Learn
[Scikit-Learn](https://scikit-learn.org/stable/) is one of the most widely used machine learning libraries used by data scientists everywhere. In this first example we're going to train a model in sci-kit learn and then use ELI5 to get an explanation for it. Make sure you have your python environment set up and [scikit-learn](https://scikit-learn.org/stable/) installed.
If you recognise the following example that's because it is also the example that [ELI5 use in their documentation](https://eli5.readthedocs.io/en/latest/tutorials/black-box-text-classifiers.html#example-problem-lsa-svm-for-20-newsgroups-dataset) but I've added some commentary to what's happening in the code snippets.
We are going to train a [Support Vector Machine (SVM)](https://en.wikipedia.org/wiki/Support-vector_machine) model to predict which newsgroup an email came from thanks to the [20 newsgroup](https://scikit-learn.org/stable/datasets/real_world.html#newsgroups-dataset) dataset. SVMs with a linear kernel do have feature coefficients which could be used to provide global feature importance. However, to make it harder we will be using an [RBF](https://en.wikipedia.org/wiki/Radial_basis_function_kernel) kernel and we will use [Latent Semantic Analysis](https://en.wikipedia.org/wiki/Latent_semantic_analysis) because that's the setup used in the example and it's a combination that cannot be explained simply without LIME.
#### Why SVM and LSA?
So why do they used RBF and LIME? Is it a contrived example just to show off LIME?
Well LSA is often used as a way to get more performance from an underlying [BoW](https://en.wikipedia.org/wiki/Bag-of-words_model) model by reducing dimensionality and combining commonly co-occuring words into a single feature (rather than having one feature per word). With LSA we might be able to do a better job of capturing some of the general 'topics' and themes that occur across a whole document rather than just tracking words and key phrases (n-grams). This could help with scenarios like the spammer above where LSA could put co-occurences of 'widow', 'million' and 'usd' in one dimension and 'term sheet', 'million', 'usd' in another dimension, giving the SVM a bit of context for the words 'million' and 'usd'.
RBF is a SVM kernel that can separate data that is not linearly seperable and there's a great explanation of this [here](https://www.kdnuggets.com/2016/06/select-support-vector-machine-kernels.html). RBF is often cited as a [reasonable first choice](https://www.csie.ntu.edu.tw/~cjlin/papers/guide/guide.pdf) of kernel for SVMs. However, NLP practitioners will generally [recommend a linear kernel for text classification](https://www.svm-tutorial.com/2014/10/svm-linear-kernel-good-text-classification/) as in practice, and in my experience, text is usually linearly separable. However it will always depend on dataset so do some visualisation during exploratory analysis to see if an RBF kernel is appropriate.
#### Training the Model
First we are going to use scikit-learn's built in [fetch_20newsgroups](https://scikit-learn.org/stable/modules/generated/sklearn.datasets.fetch_20newsgroups.html#sklearn.datasets.fetch_20newsgroups) helper function to download some example emails from 4 newsgroups. There could reasonably be some serious overlap between the atheism and christian boards so this might be where LSA and our RBF kernel come in handy.
```python
from sklearn.datasets import fetch_20newsgroups
categories = ['alt.atheism', 'soc.religion.christian',
'comp.graphics', 'sci.med']
twenty_train = fetch_20newsgroups(
subset='train',
categories=categories,
shuffle=True,
random_state=42,
remove=('headers', 'footers'),
)
twenty_test = fetch_20newsgroups(
subset='test',
categories=categories,
shuffle=True,
random_state=42,
remove=('headers', 'footers'),
)
```
In the next code snippet we train the code. The [TFIDFVectorizer](https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html) splits the texts into tokens, builds a bag-of-words representation of the text but with the addition of [TF-IDF](https://en.wikipedia.org/wiki/Tf%E2%80%93idf) information to help us filter out words that don't give us any information.
The [TruncatedSVD](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.TruncatedSVD.html) object is applied to the TFIDF vectorizer to give us our latent signals/categories.
Then the [SVC](https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html) is fed the output of the SVD/LSA component.
Each component is linked together into a [Pipeline](https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.Pipeline.html) object that basically provides syntactic sugar for us later and avoids us having to manually define an interface for ELI5 to call in order to use our model.
Finally we call `pipe.fit()` on the training data to actually feed the pipeline and train the model and `pipe.score()` on the test set to give us a top-line accuracy (if we were doing a thorough job we should probably also look at [other appropriate metrics](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.classification_report.html)).
`random_state` is simply a number that is used to seed Python's pseudo-random number generator which
scikit-learn usesfor pseudo-random operations. Setting random state explicitly is a good habit to get
into in order to preserve the reproducibility of your models.
Another key parameter set here is `probability=True` on the SVM. This will allow us to get the probability distributions
across the classes that LIME will need later. If you don't set this then `predict_proba()` will fail at the next step.
```python
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import SVC
from sklearn.decomposition import TruncatedSVD
from sklearn.pipeline import Pipeline, make_pipeline
vec = TfidfVectorizer(min_df=3, stop_words='english',
ngram_range=(1, 2))
svd = TruncatedSVD(n_components=100, n_iter=7, random_state=42)
lsa = make_pipeline(vec, svd)
clf = SVC(C=150, gamma=2e-2, probability=True)
pipe = make_pipeline(lsa, clf)
pipe.fit(twenty_train.data, twenty_train.target)
pipe.score(twenty_test.data, twenty_test.target)
```
#### Getting Some Predictions
Now that the model is trained it is possible to run it on unseen data and get a prediction. In the tutorial
the ELI5 authors provide a pretty printing function that shows the probability distribution of the labels for
a given example.
```python
def print_prediction(doc):
y_pred = pipe.predict_proba([doc])[0]
for target, prob in zip(twenty_train.target_names, y_pred):
print("{:.3f} {}".format(prob, target))
doc = twenty_test.data[0]
print_prediction(doc)
```
This is basically just predicting the classes for the given document, which is the first doc in the test set, and then combining the probabilities in the prediction (`y_pred`) with the class names (`twenty_train.target_names`).
#### Getting an Explanation
Getting an explanation of out this model is relatively simple at this point. We simply import the [TextExplainer](https://eli5.readthedocs.io/en/latest/autodocs/lime.html#eli5.lime.lime.TextExplainer) class from ELI5 and `fit()` it to the document (the first one in the test set as per the above snippet). The TextExplainer will use the SVC pipeline `pipe` to make predictions for a bunch of perturbed examples and train its own model. The `show_predictions` function will then give a visualisation of the explanation. The `target_names=` parameter is used to pass the class names from our dataset to the text explainer so that they can be displayed nicely.
```python
import eli5
from eli5.lime import TextExplainer
te = TextExplainer(random_state=42)
te.fit(doc, pipe.predict_proba)
te.show_prediction(target_names=twenty_train.target_names)
```
Et voila! Hopefully you will get some output that looks like the below:
{{<figure src="images/explanation_svm.png" caption="The output of the explain functon should look something like this">}}
Finally we can look at the model weights too
```python
te.explain_weights(target_names=twenty_train.target_names)
```
{{<figure src="images/weights.png" caption="Model feature weights">}}
### ELI5 and Transformers/Huggingface
[Transformers](https://huggingface.co/docs/transformers/index) is an open source library provided by HuggingFace which provides an easy to use wrapper around PyTorch and Tensorflow specifically to make it easy to use transformer-based NLP models like BERT, RoBERTa etc. In order to use ELI5 with Transformers from huggingface, we need to have Python3, [transformers](https://huggingface.co/docs/transformers/index) and a recent version of [pytorch](https://pytorch.org/) installed.
This example will work on a machine without a GPU provided you aren't planning on training your transformer model from scratch. I am using [this sentiment model](https://huggingface.co/nlptown/bert-base-multilingual-uncased-sentiment) which evaluates the sentiment/rating of reviews from 1 to 5 in English, Dutch, German, French or Spanish.
#### Why Transformers?
Transformer-based models are, at the time of writing, **the in thing** for NLP models - they are a type of deep neural network that has contextual understanding of full sentences. If you're not familiar with them [this article](https://towardsdatascience.com/transformers-89034557de14) offers a fairly good introduction.
There are good reasons for not using transformers - first and foremost is that they are very computationally expensive to train and somewhat computationally expensive during inference (as you will see if you run both the above SVM experiment and the below transformer experiment). If you find that a less powerful (both in terms of understanding and in terms of power consumption) model works for your use case then using that instead is probably a good move - it'll save you headaches later if you need to scale up your inference operation.
#### Loading The Model
The following snippet of code simply loads the model into memory amd sets up the tokenizer ready for use with new text examples
```python
from transformers import AutoModelForSequenceClassification
from transformers import AutoTokenizer
import numpy as np
import pandas as pd
from typing import List
# this is the name of the model we want to evaluate on
# huggingface.com/models or alternatively you could train your own
MODEL="nlptown/bert-base-multilingual-uncased-sentiment"
tokenizer = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForSequenceClassification.from_pretrained(MODEL)
```
#### Defining the Interface with ELI5
This snippet of code defines the all important `model_adapter` function which we use to interface between PyTorch and ELI5.
ELI5 expects to be able to pass in a list of perturbed texts and get back a set of probability distributions (a matrix in the shape [NUM_EXAMPLES, NUM_CLASSES]).
In our function we have to encode the text into a BERT compatible input format using the [tokenizer](https://huggingface.co/transformers/main_classes/tokenizer.html).
Then we pass the encoded input to the model and receive some predictions.
Finally we use `softmax()` which will convert the raw *logits* generated by the model into nice smooth probability functions that LIME is expecting to see.
You may be wondering about the for loop and the batches? ELI5 tries to get results for 5000 samples at a time (by default) and that might be fine in a smaller, less powerful model but with a transformer we can't fit all of those examples into memory. Therefore we split the samples into batches of 64 at a time so that we don't end up running out of RAM.
```python
def model_adapter(texts: List[str]):
all_scores = []
for i in range(0, len(texts), 64):
batch = texts[i:i+64]
# use bert encoder to tokenize text
encoded_input = tokenizer(batch,
return_tensors='pt',
padding=True,
truncation=True,
max_length=model.config.max_position_embeddings-2)
# run the model
output = model(**encoded_input)
# by default this model gives raw logits rather
# than a nice smooth softmax so we apply it ourselves here
scores = output[0].softmax(1).detach().numpy()
all_scores.extend(scores)
return np.array(all_scores)
```
#### Getting an Explanation
The last piece in the puzzle is to actually run the model and get our explanation. Firstly we initialize our explainer object.
`n_samples` gives the number of perturbed examples that LIME should generate in order to train the local model (more samples
should give a more faithful local explanation at the cost of more compute/taking longer). Note that as above, we manually set
`random_state` for reproducibility.
Next we pass the text that we'd like to get an explanation for and the model_adapter function into `fit()` - this will trigger
ELI5 to train a LIME model using our transformer model which could take a few seconds or minutes depending on what sort of machine
spec you have.
Finally, we render the explanation using `te.explain_prediction()`. We pass `target_names=list(model.config.id2label.values())` which
tells the `TextExplainer` what the class names from the bert model are (class names are stored in `config.id2label` by convention in
[Huggingface transformer configurations](https://huggingface.co/docs/transformers/main_classes/configuration) but this function will accept
any list of strings that is the same length as the number of classes in the model).
```python
from eli5.lime import TextExplainer
te = TextExplainer(n_samples=5000, random_state=42)
te.fit("""The restaurant was amazing, the quality of their
food was exceptional. The waiters were so polite.""", model_adapter)
te.explain_prediction(target_names=list(model.config.id2label.values()))
```
Et voila! Hopefully you will get some output that looks like the below:
{{<figure src="images/explanation_example.png" caption="The output of the explain functon should look something like this">}}
You might also want to check the model weights with:
```python
te.explain_weights(target_names=list(model.config.id2label.values()))
```
### ELI5 and a Remotely Hosted Model / API
This one is quite fun and exciting. Since LIME is model agnostic, we can get an explanation for a remotely hosted model assuming we have access to
the full probability distribution over its labels (and assuming you have enough API credits to train your local model).
In this example I'm using Huggingface's [inference api](https://api-inference.huggingface.co/docs/python/html/quicktour.html) where they host transformer models on your behalf - you can pay to have your models run on GPUs for higher throughput. I made this guide with the free tier allowance which gives you 30k tokens per month - if you are using LIME with default settings you could easily eat through this whilst generating a single explanation so this is yet again a contrived example that gives you a taster of what is possible.
#### Setting up
For this part of the tutorial you will need the Python [requests](https://docs.python-requests.org/en/latest/) library and we are also going to make use of [scipy](https://docs.scipy.org). You will also need a huggingface account and you will need to set up your API key as described in the [documentation](https://api-inference.huggingface.co/docs/python/html/quicktour.html).
#### Building a Remote Model Adapter
Firstly we need to build a model adapter function that allows ELI5 to interface with huggingface's models.
```python
import json
import requests
MODEL="nlptown/bert-base-multilingual-uncased-sentiment"
API_TOKEN="YOUR API KEY HERE"
API_URL = f"https://api-inference.huggingface.co/models/{MODEL}"
headers = {"Authorization": f"Bearer {API_TOKEN}"}
def query(payload):
data = json.dumps(payload)
response = requests.request("POST", API_URL, headers=headers, data=data)
return json.loads(response.content.decode("utf-8"))
def result_to_df(result):
rows = []
for result_row in result:
row = {}
for lbl_score in result_row:
row[lbl_score['label']] = lbl_score['score']
rows.append(row)
return pd.DataFrame(rows)
def remote_model_adapter(texts: List[str]):
all_scores = []
for text in texts:
data = query(text)
all_scores.extend(result_to_df(data).values)
return softmax(np.array(all_scores), axis=1)
```
## Checking whether the explanation is trustworthy
How do we know if our explanations are good? Like any other ML model, the models produced by LIME should be evaluated using a held-out/unseen test set of perturbed examples that have not been seen before. If the local model can do well at predicting the black box weights for other, local examples that it's not seen yet, then we can assume that the model is a good fit (at least within the specific 'locality' under analysis).
When we evaluate the local model against the black box model we want to know that, at the very least, the local model is making the same class predictions as the parent black-box model (do both the child model and parent model predict the same most likely class). However, it is also useful to know precisely how similar those outputs are (given that both models predict the same 'most likely' class, what is the percentage difference in probability between the two predictions). A good local model should produce a very similar probability distribution to the parent black-box model for the same inputs. Therefore we use [KL-Divergence](https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence) as our performance metric in order to evaluate how well the model is performing. In a nutshell KL-Divergence tells you how similar 2 probability distributions are - and we want this number to be as small as possible (i.e. the probability distributions are pretty much the same).
ELI5 provides this functionality all for free (generates a test set of perturbed examples and evalutes the final model automatically) so all we need is to look at the metrics and interpret them. For any of the above examples you should be able to run `te.metrics_` in Jupyter to get an output similar to the one below:
```
{'mean_KL_divergence': 0.01961629150756376, 'score': 0.9853027527973619}
```
The `score` metric is our local model accuracy which is 98.5% - that's quite reassuring. The mean KL Divergence is low at 0.0196 - this can be interpreted as a mean difference/divergence in the predictions of about 2% across the whole dataset which seems acceptable.
If these KL divergence is high or the score is low then you have a bad local model and it's worth checking to see why that might be the case and probably best not to trust the results. The [ELI5 Documentation](https://eli5.readthedocs.io/en/latest/tutorials/black-box-text-classifiers.html#should-we-trust-the-explanation) has some excellent information on specific cases where your NLP model might fail and how you might go about diagnosing these issues.
## Conclusion
In this post I have given you an insight into how LIME works under the covers and how it uses simple local models to offer explanations of more powerful black-box models. I've discussed some of the limitations of this approach and given some practical code examples for how you could apply LIME to commonly used frameworks in Python as well as a remote model API.
If you enjoyed this article please take a moment to tweet, toot or send me a webmention.

File diff suppressed because one or more lines are too long

View File

@ -1388,5 +1388,228 @@
"published": "2015-11-08T03:38:09+00:00" "published": "2015-11-08T03:38:09+00:00"
} }
} }
],
"\/2022\/01\/15\/festive-shock\/": [
{
"source": "https:\/\/brid.gy\/like\/mastodon\/@jamesravey@fosstodon.org\/107631435211529130\/275065",
"verified": true,
"verified_date": "2022-01-16T22:55:07+00:00",
"id": 1333016,
"private": false,
"data": {
"author": {
"name": "edel",
"url": "https:\/\/fosstodon.org\/@edel",
"photo": "https:\/\/webmention.io\/avatar\/cdn.fosstodon.org\/e51f4d3793e22e66e0dbb40d8ec75866592fddda31caf8e75e1f797258945ad5.png"
},
"url": "https:\/\/fosstodon.org\/@jamesravey\/107631435211529130#favorited-by-275065",
"name": null,
"content": null,
"published": null,
"published_ts": null
},
"activity": {
"type": "like",
"sentence": "edel liked a post https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/",
"sentence_html": "<a href=\"https:\/\/fosstodon.org\/@edel\">edel<\/a> liked a post <a href=\"https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/\">https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/"
},
{
"source": "https:\/\/brid.gy\/comment\/mastodon\/@jamesravey@fosstodon.org\/107631435211529130\/107633571730678691",
"verified": true,
"verified_date": "2022-01-16T22:55:05+00:00",
"id": 1333015,
"private": false,
"data": {
"author": {
"name": "edel",
"url": "https:\/\/fosstodon.org\/@edel",
"photo": "https:\/\/webmention.io\/avatar\/cdn.fosstodon.org\/e51f4d3793e22e66e0dbb40d8ec75866592fddda31caf8e75e1f797258945ad5.png"
},
"url": "https:\/\/fosstodon.org\/@edel\/107633571730678691",
"name": null,
"content": "<p><span class=\"h-card\"><a href=\"https:\/\/fosstodon.org\/@jamesravey\" class=\"u-url\">@<span>jamesravey<\/span><\/a><\/span> I love this post! I always put so much pressure on myself to start off with a clean once January comes. But like anything, taking small steps is probably better in the long run compared to just going \"cold turkey\".<\/p>",
"published": "2022-01-16T18:32:47+00:00",
"published_ts": 1642357967
},
"activity": {
"type": "reply",
"sentence": "edel commented '@jamesravey I love this post! I always put so much pressure on myself to start o...' on a post https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/",
"sentence_html": "<a href=\"https:\/\/fosstodon.org\/@edel\">edel<\/a> commented '@jamesravey I love this post! I always put so much pressure on myself to start o...' on a post <a href=\"https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/\">https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/"
},
{
"source": "https:\/\/brid.gy\/comment\/mastodon\/@jamesravey@fosstodon.org\/107631435211529130\/107633705441297819",
"verified": true,
"verified_date": "2022-01-16T22:55:04+00:00",
"id": 1333014,
"private": false,
"data": {
"author": {
"name": "James Ravenscroft",
"url": "https:\/\/fosstodon.org\/@jamesravey",
"photo": "https:\/\/webmention.io\/avatar\/cdn.fosstodon.org\/1db69bf6408d549f11885537e17a22d22c58563968b1d6462a3f8e6d47619670.png"
},
"url": "https:\/\/fosstodon.org\/@jamesravey\/107633705441297819",
"name": null,
"content": "<p><span class=\"h-card\"><a href=\"https:\/\/fosstodon.org\/@edel\" class=\"u-url\">@<span>edel<\/span><\/a><\/span> agreed! I find it's so much easier to justify just giving up to yourself if you try to make a lot of changes at once and they don't stick. Much prefer to introduce one \"better\" thing a week and make sure the habits bed in before I try and change the next thing!<\/p>",
"published": "2022-01-16T19:06:47+00:00",
"published_ts": 1642360007
},
"activity": {
"type": "reply",
"sentence": "James Ravenscroft commented '@edel agreed! I find it's so much easier to justify just giving up to yourself i...' on a post https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/",
"sentence_html": "<a href=\"https:\/\/fosstodon.org\/@jamesravey\">James Ravenscroft<\/a> commented '@edel agreed! I find it's so much easier to justify just giving up to yourself i...' on a post <a href=\"https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/\">https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/2022\/01\/15\/festive-shock\/"
}
],
"\/notes\/2022\/01\/17\/1642417320\/": [
{
"source": "https:\/\/brid.gy\/like\/twitter\/jamesravey\/1483038438672769026\/274656383",
"verified": true,
"verified_date": "2022-01-17T12:47:03+00:00",
"id": 1333424,
"private": false,
"data": {
"author": {
"name": "James Sutton",
"url": "https:\/\/twitter.com\/jpwsutton",
"photo": "https:\/\/webmention.io\/avatar\/pbs.twimg.com\/0f4f1a53e2b5cc0832122cf12eeb07dafd49b2ec784eb56f1a7933b5029a26ed.jpg"
},
"url": "https:\/\/twitter.com\/jamesravey\/status\/1483038438672769026#favorited-by-274656383",
"name": null,
"content": null,
"published": null,
"published_ts": null
},
"activity": {
"type": "like",
"sentence": "James Sutton favorited a tweet https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/",
"sentence_html": "<a href=\"https:\/\/twitter.com\/jpwsutton\">James Sutton<\/a> favorited a tweet <a href=\"https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/\">https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/"
},
{
"source": "https:\/\/brid.gy\/like\/twitter\/jamesravey\/1483038438672769026\/896776614",
"verified": true,
"verified_date": "2022-01-17T12:47:02+00:00",
"id": 1333423,
"private": false,
"data": {
"author": {
"name": "Will Held",
"url": "https:\/\/twitter.com\/WilliamBarrHeld",
"photo": "https:\/\/webmention.io\/avatar\/pbs.twimg.com\/2435579ae3ed481347309496ba1603a32e00578f39c7d6353cf1be37b15ff0e2.jpg"
},
"url": "https:\/\/twitter.com\/jamesravey\/status\/1483038438672769026#favorited-by-896776614",
"name": null,
"content": null,
"published": null,
"published_ts": null
},
"activity": {
"type": "like",
"sentence": "Will Held favorited a tweet https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/",
"sentence_html": "<a href=\"https:\/\/twitter.com\/WilliamBarrHeld\">Will Held<\/a> favorited a tweet <a href=\"https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/\">https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/"
},
{
"source": "https:\/\/brid.gy\/comment\/mastodon\/@jamesravey@fosstodon.org\/107637566776108192\/107637675378080938",
"verified": true,
"verified_date": "2022-01-17T13:08:41+00:00",
"id": 1333433,
"private": false,
"data": {
"author": {
"name": "Andreas Gerlach",
"url": "https:\/\/fosstodon.org\/@appelgriebsch",
"photo": "https:\/\/webmention.io\/avatar\/cdn.fosstodon.org\/20f4a6d2f4c172dae3ddfa913b34d0101680572dba6292a7d367f3eda763cadf.jpg"
},
"url": "https:\/\/fosstodon.org\/@appelgriebsch\/107637675378080938",
"name": null,
"content": "<p><span class=\"h-card\"><a href=\"https:\/\/fosstodon.org\/@jamesravey\" class=\"u-url\">@<span>jamesravey<\/span><\/a><\/span> looks to me like she is watching the mouse ;)<\/p>",
"published": "2022-01-17T11:56:23+00:00",
"published_ts": 1642420583
},
"activity": {
"type": "reply",
"sentence": "Andreas Gerlach commented '@jamesravey looks to me like she is watching the mouse ;)' on a post https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/",
"sentence_html": "<a href=\"https:\/\/fosstodon.org\/@appelgriebsch\">Andreas Gerlach<\/a> commented '@jamesravey looks to me like she is watching the mouse ;)' on a post <a href=\"https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/\">https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/"
},
{
"source": "https:\/\/brid.gy\/like\/twitter\/jamesravey\/1483038438672769026\/1374136698",
"verified": true,
"verified_date": "2022-01-17T13:59:59+00:00",
"id": 1333454,
"private": false,
"data": {
"author": {
"name": "LouiseAndTheFeathers",
"url": "https:\/\/twitter.com\/LouiseATF",
"photo": "https:\/\/webmention.io\/avatar\/pbs.twimg.com\/eab18f4c24dccdf7a7572d0a5235941d7bc09da6dcad91e97d236e7b6b97f08c.jpg"
},
"url": "https:\/\/twitter.com\/jamesravey\/status\/1483038438672769026#favorited-by-1374136698",
"name": null,
"content": null,
"published": null,
"published_ts": null
},
"activity": {
"type": "like",
"sentence": "LouiseAndTheFeathers favorited a tweet https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/",
"sentence_html": "<a href=\"https:\/\/twitter.com\/LouiseATF\">LouiseAndTheFeathers<\/a> favorited a tweet <a href=\"https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/\">https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/"
},
{
"source": "https:\/\/brid.gy\/like\/mastodon\/@jamesravey@fosstodon.org\/107637566776108192\/321008",
"verified": true,
"verified_date": "2022-01-17T13:56:44+00:00",
"id": 1333452,
"private": false,
"data": {
"author": {
"name": "bjb :devuannew: :emacs:",
"url": "https:\/\/fosstodon.org\/@bjb",
"photo": "https:\/\/webmention.io\/avatar\/cdn.fosstodon.org\/d7521bb9d4f439be56d7b2fd4f5f011eab1a2a923cd686846a4a84272fb8fbe2.png"
},
"url": "https:\/\/fosstodon.org\/@jamesravey\/107637566776108192#favorited-by-321008",
"name": null,
"content": null,
"published": null,
"published_ts": null
},
"activity": {
"type": "like",
"sentence": "bjb :devuannew: :emacs: liked a post https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/",
"sentence_html": "<a href=\"https:\/\/fosstodon.org\/@bjb\">bjb :devuannew: :emacs:<\/a> liked a post <a href=\"https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/\">https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/<\/a>"
},
"target": "https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/"
},
{
"id": 1333783,
"source": "https:\/\/brid.gy\/like\/twitter\/jamesravey\/1483038438672769026\/9401342",
"target": "https:\/\/brainsteam.co.uk\/notes\/2022\/01\/17\/1642417320\/",
"activity": {
"type": "like"
},
"verified_date": "2022-01-17T18:03:49.953648",
"data": {
"author": {
"type": "card",
"name": "Nicolas Du Moulin",
"photo": "https:\/\/webmention.io\/avatar\/pbs.twimg.com\/c31ab2972743db5c8d355ea714487b65e8b308b08ee5b93fad5da69711064b33.jpg",
"url": "https:\/\/twitter.com\/nicdm"
},
"content": null,
"published": null
}
}
] ]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB