Il existe bien sur beaucoup de librairies (comme Pandas Profiling ou plutôt ydata-profiling
dorénavant) et surtout beaucoup d’outils qui permettent plus ou moins simplement d’effectuer une analyse structurelle de vos données (on se limitera ici d’un profiling de table).
Mais voilà, parfois une approche minimaliste suffit amplement. Rien ne nécessite la mise en place d’une artillerie lourde et couteuse en ressource ! L’idée de cet article est de vous montrer qu’avec la simple (mais très riche) librairie Pandas vous avez déjà de quoi vous satisfaire amplement.
Ne l’oubliez pas la loi de Pareto stipule que 80% de la valeur est dégagée par seulement 20% des efforts … alors voulez vous vraiment vous fatiguer pour peu ou pas grand chose ?
Préparation
Les ingrédients pour la recette sont très simple puisque comme mentionné plus haut nous nous contenterons de n’utiliser que la librairie Python Pandas !
L’objectif quant à lui est plutôt simple, analyser les données d’un dataset et y renvoyer ces informations dans un format dict/JSON. Pourquoi JSON, tout simplement parce que l’analyse va renvoyer des données qui peuvent être assimilées à un arbre (parfait donc pour JSON). On trouvera tout d’abord les informations générales du jeu de données comme le nombre de lignes, de colonnes, etc. puis pour chaque colonne nous renverrons une analyse plus détaillée bien sur:
- Nom
- Type
- Pattern (format)
- Valeurs les plus fréquentes
- Type réel
- Statistiques
- etc.
Voici un exemple de ce que l’on désirerait connaitre à la suite de cette analyse:
{
"rows count": 14,
"columns count": 16,
"columns names": [
"id","concept:name", ...
],
"columns": [
{
"name": "id",
"type": "float64",
"inferred": "float64",
"distinct": 12,
"nan": 2,
"null": 2,
"stats": {
"count": 12,"mean": 6.166666666666667, ...
},
"top values": {
"0.0": 1, "1.0": 1, ...
},
"pattern": {
"N.N": 9, "NN.N": 3,...
},
"types": {
"number": 12, "null": 2, ...
}
},
...
]
}
Une fois de plus avec Pandas nous avons tout ce qu’il faut pour fournir ce type de résultat et ce en quelques lignes de code …
Première analyse
Commençons par importer les librairies nécessaires et créer un jeu de données d’essai:
import re
import pandas as pd
import math
import datetime
from collections import Counter
data = {
'A': ['abc123', 'def/456', 'ghi-789', 'jkl101', 'AAA', '111', None, 'vwx145', 'yz156', 'lmn167'],
'B': [1, 1, 3, 4, 5, 5, 7, 8, 9, 9],
'C': ['foo', 1, 'foo', 'bar', 15, 'bar', 'foo', "2023/01/10", 'foo', 'bar']
}
df = pd.DataFrame(data)
Ok, j’ai dit qu’on utiliserait que Pandas, mais en réalité nous allons aussi utiliser des librairies de base Python comme re pour les Regex, math, datetime, etc. 😉
Le jeu de données est lui volontairement très simple, j’y ai néanmoins ajouté quelques soucis comme des valeurs manquantes, des types multiples sur la même colonne, etc.
Détection de format
Voilà une fonctionnalité particulièrement pratique quand on ne connait pas son jeu de données et que l’on veut voir si les colonnes ont des formats particuliers. La détection de format ou de pattern peut s’effectuer en quelques lignes seulement, et grâce aux regex on peut faire à peut près tout ce que l’on veut. Ci dessous une simple fonction qui permet de détecter un pattern numérique ou de type chaine. L’idée est plutot simple, à la lecture de la donnée, la fonction va remplacer chaque chiffre par N et chaque caractère (hors chiffre) par C. J’ai aussi rajouté la notion de bruit pour tous les autres caractères qui ne m’interresserait pas.
REGEX_CARS = r'[a-zA-Z]'
REGEX_NUMS = r'\d'
REGEX_NOISE = r'[µ&#@^~]'
REGEX_SPECCARS = r'[<>µ+=*&\'"#{}()|/\\@][]^~]'
REGEX_PONCT = r'[,.;:?!]'
def getStringPattern(string):
if (string == 'nan' or string == None):
return "NULL"
mystr = re.sub(REGEX_CARS, 'C', string)
mystr = re.sub(REGEX_NUMS, 'N', mystr)
mystr = re.sub(REGEX_NOISE, '?', mystr)
return mystr
df2 = pd.DataFrame()
df2['profile'] = df['A'].apply(getStringPattern)
print(df2)
profile
0 CCCNNN
1 CCC/NNN
2 CCC-NNN
3 CCCNNN
4 CCC
5 NNN
6 NULL
7 CCCNNN
8 CCNNN
9 CCCNNN
Pratique n’est ce pas ? notamment pour trouver une codification dans un jeu de données complexe, etc.
Maintenant pour placer le résultat dans un JSON (ou dictionnaire Python), rien de plus simple, on utilise la fonction to_dict() de pandas :
print(df2['profile'].value_counts().to_dict())
{'CCCNNN': 4,
'CCC/NNN': 1,
'CCC-NNN': 1,
'CCC': 1,
'NNN': 1,
'NULL': 1,
'CCNNN': 1}
Découvrir le type réel
Maintenant attaquons nous à une autre analyse extrêmement pratique : la détection du type réel de la donnée stockée. Nous savons en effet que les chaines de caractères servent de fourre-tout dans lesquelles on peut stocker à peut près n’importe quoi: Nombre, dates, etc. il serait intéressant donc d’analyser le contenu réel de chaque colonne et de renvoyer quelques statistiques concrètes. C’est l’objectife de la fonction suivante qui va renvoyer le type réel de la donnée:
def getType(value):
"""Returns the data type on the value in input
Args:
value (object): data
Returns:
str: type namen can be [ null, number, date, string, unknown]
"""
if value is None:
return "null"
if isinstance(value, (int, float)):
if math.isnan(value):
return "null"
else:
return "number"
elif isinstance(value, str):
return "string"
elif isinstance(value, datetime.datetime):
return "date"
else:
return "unknown"
Essayons la sur notre jeu de données:
df2 = pd.DataFrame()
df2['types'] = df['C'].apply(getType)
print(df2)
types
0 string
1 number
2 string
3 string
4 number
5 string
6 string
7 string
8 string
9 string
Hum, c’est pas mal mais il semblerait que ma date « 2023/01/10 » ait été détectée comme une chaine de caractère (ce qui est normal) … je voudrais aussi détecter des dates dans mon jeu de données ! pour cela nous allons utiliser un module supplémentaire (dateutil) qui va faire ce travail pour nous et voici la nouvelle fonction:
def getType(value):
"""Returns the data type on the value in input
Args:
value (object): data
Returns:
str: type namen can be [ null, number, date, string, unknown]
"""
if value is None:
return "null"
if isinstance(value, (int, float)):
if math.isnan(value):
return "null"
else:
return "number"
elif isinstance(value, str):
try:
parse(value, fuzzy=False)
return "date"
except ValueError:
return "string"
elif isinstance(value, datetime.datetime):
return "date"
else:
return "unknown"
Relançons la détection …
df2 = pd.DataFrame()
df2['types'] = df['C'].apply(getType)
print(df2)
types
0 string
1 number
2 string
3 string
4 number
5 string
6 string
7 date
8 string
9 string
C’est beaucoup mieux 🙂
Fonction globale
Voilà nous avons maintenant (outre les fonctions intrinsèques de Pandas) pour fournir notre profiling de données, voici donc la fonction que l’on pourrait utiliser:
def profile(df, maxvaluecounts=10) -> dict:
"""Build a JSON which contains some basic profiling informations
Args:
maxvaluecounts (int, optional): Limits the number of value_counts() return. Defaults to 10.
Returns:
json: data profile in a JSON format
"""
profile = {}
# Get stats per columns/fields
profileColumns = []
for col in df.columns:
profileCol = {}
counts = df[col].value_counts()
profileCol['name'] = col
profileCol['type'] = str(df[col].dtypes)
profileCol['inferred'] = str(df[col].infer_objects().dtypes)
profileCol['distinct'] = int(len(counts))
profileCol['nan'] = int(df[col].isna().sum())
profileCol['null'] = int(df[col].isnull().sum())
profileCol['stats'] = df[col].describe().to_dict()
profileCol['top values'] = dict(Counter(counts.to_dict()).most_common(maxvaluecounts))
# Get pattern for that column
dfProfPattern = pd.DataFrame()
dfProfPattern['profile'] = df[col].apply(lambda x:getStringPattern(str(x)))
profileCol['pattern'] = dict(Counter(dfProfPattern['profile'].value_counts().to_dict()).most_common(maxvaluecounts))
# get types for that columns
dfProfType = pd.DataFrame()
dfProfType['types'] = df[col].apply(lambda x:getType(x))
profileCol['types'] = dict(Counter(dfProfType['types'].value_counts().to_dict()).most_common(maxvaluecounts))
profileColumns.append(profileCol)
profile["rows count"] = df.shape[0]
profile["columns count"] = len(df.columns)
profile["columns names"] = [ name for name in df.columns ]
profile["columns"] = profileColumns
return profile
Maintenant essayez là en tapant juste:
profile(df)
Vous remarquerez le paramètre maxvaluecounts qui permet de limiter le nombre de résultat sur certaines sous fonctions comme les valeurs les plus fréquentes ou même les types les plus utilisés.
Conclusion
L’objectif de ce post était juste de montrer que bien souvent il est très simple d’utiliser des fonctions déjà à disposition plutôt que d’importer des librairies ou pire d’utiliser des outils plus complexe. Cela ne signifie pas bien sur qu’il faut toujours avoir cette approche « minimaliste » mais à contrario adapter au besoins réel tout en limitant le code et les ressources permet aussi de limiter les erreurs et la maintenance. En bref avant d’installer un outil de Profiling, il est intéressant de bien s’interroger sur l’objectif réel de cette analyse et des bénéfices que vous en attendez.
Note: Cette fonction est dorénavant native dans le projet pipelite.
Ingénieur en informatique avec plus de 20 ans d’expérience dans la gestion et l’utilisation de données, Benoit CAYLA a mis son expertise au profit de projets très variés tels que l’intégration, la gouvernance, l’analyse, l’IA, la mise en place de MDM ou de solution PIM pour le compte de diverses entreprises spécialisées dans la donnée (dont IBM, Informatica et Tableau). Ces riches expériences l’ont naturellement conduit à intervenir dans des projets de plus grande envergure autour de la gestion et de la valorisation des données, et ce principalement dans des secteurs d’activités tels que l’industrie, la grande distribution, l’assurance et la finance. Également, passionné d’IA (Machine Learning, NLP et Deep Learning), l’auteur a rejoint Blue Prism en 2019 et travaille aujourd’hui en tant qu’expert data/IA et processus. Son sens pédagogique ainsi que son expertise l’ont aussi amené à animer un blog en français (datacorner.fr) ayant pour but de montrer comment comprendre, analyser et utiliser ses données le plus simplement possible.