Der Untergang der Titanic

Natürlich kann man Daten mit Phyton und R analysieren, aber wie wäre es einmal mit dem guten alten SQL.
​
Hier zeige ich meinen Prozess einer reinen SQL Datenanalyse mit den Daten der Titanic Katastrophe.
​
Es ist ein bischen länger zu lesen, aber spannend bis zum Schluss
Einleitung
Auch in 2022 ist SQL das meist verbreitete Datenbankmanagement System: Most popular database management systems 2022 | Statista. Der Titanic-Datensatz ist eine relativ einfache csv-Datei, die die Daten von 1306 Passagieren und ihr Schicksal in der Nacht zum 15. April 1912 enthält.
Vorbereitung
Ich verwende die SQL Server 2019 Installation und SQL Server Management Studio 18. Die Daten sind in einer CSV-Datei, hier: https://data.world/nrippner/titanic-disaster-dataset.
Schritt 1: die Datenbank und Tabelle erstellen
Ich habe die Datenbank und die Tabelle dbo.passengers erstellt, um die Daten zu laden habe ich die BULK INSERT-Anweisung verwendet.
use titanic;
drop table if exists dbo.passengers;
create table passengers
(
passengerID int primary key,
pclass smallint not null,
survived smallint not null,
last_name varchar(50),
first_name varchar(100),
sex varchar(10),
age decimal(12,2),
sibsp smallint,
parch smallint,
ticket varchar(50),
fare decimal(12,2),
cabin varchar(30),
embarked varchar(30),
boat varchar(30),
body int,
homedest varchar(50)
)
BULK INSERT dbo.passengers
FROM 'D:\COMPUTER\data\nrippner-titanic-disaster-dataset\nrippner-titanic-disaster-dataset\original\titanic_disaster_original2.csv'
WITH
(
FORMAT='CSV',
FIRSTROW=2
);

Schritt 2: die Daten auf Null Werte prüfen
Fehlende Werte bzw. Null Werte haben einen erheblichen Einfluss auf die Datenqualität und somit auf die Ergebnisse der Datenanalyse. Für jede Datenanalyse-Aufgabe gilt nach wie vor der Satz „Shit in – shit out“. Zunächst prüfe ich also, wie viele leere Zeilen jede Spalte hat und gebe deren Prozentsatz zurück.
with CTE1 as
(
select
cast(100.00 * sum(case when pclass IS NULL then 1 else 0 end)/ max(all_passengers) as decimal(6,1)) as pclass,
cast(100.00 * sum(case when survived IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as survived,
cast(100.00 * sum(case when last_name IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as lastname,
cast(100.00 * sum(case when first_name IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as firstname,
cast(100.00 * sum(case when sex IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as sex,
cast(100.00 * sum(case when age IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as age,
cast(100.00 * sum(case when sibsp IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as sib,
cast(100.00 * sum(case when parch IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as parch,
cast(100.00 * sum(case when ticket IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as ticket,
cast(100.00 * sum(case when fare IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as fare,
cast(100.00 * sum(case when cabin IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as cabin,
cast(100.00 * sum(case when embarked IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as embarked,
cast(100.00 * sum(case when boat IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as boat,
cast(100.00 * sum(case when body IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as body,
cast(100.00 * sum(case when homedest IS NULL then 1 else 0 end) / max(all_passengers) as decimal(6,1)) as home
from
(
select count(*) over() as all_passengers, *
from passengers
) sub1
)
select 'pclass' as attribute, pclass as null_percent from CTE1
union all
select 'survived', survived from CTE1
union all
select 'lastname', lastname from CTE1
union all
select 'firstname', firstname from CTE1
union all
select 'sex', sex from CTE1
union all
select 'age', age from CTE1
union all
select 'sibsp', sib from CTE1
union all
select 'parch', parch from CTE1
union all
select 'ticket', ticket from CTE1
union all
select 'fare', survived from CTE1
union all
select 'cabin', cabin from CTE1
union all
select 'embarked', embarked from CTE1
union all
select 'boat', boat from CTE1
union all
select 'body', body from CTE1
union all
select 'homedest', home from CTE1
;

Das Query zeigt, dass die Spalte [age] 20 % Nullen enthält, [cabin] 77,5 %, [boat] 62,9 %, [body] 90,8 % und [homedest] (homedestination) 43,1 %. Da ich [boat], [body] und [homedest] nicht für meine Analyse verwenden werde, bleiben nur die beiden kritischen Spalten [age] und [fare]. Mit diesen befassen wir uns später.
Datenbereich
Jetzt habe ich mir [age] und [fare] genauer angesehen, indem ich den Durchschnitt und die Spannweite der Werte berechnet habe. Beide Spalten sind rechtsschief, was bedeutet, dass die Maximalwerte weiter vom Durchschnitt entfernt sind als die Minimalwerte, d.h. es gibt Ausreißer im oberen Bereich der Werte, was sich auf den Durchschnitts auswirkt.
select 'age' as attribute, count (distinct coalesce(age,0)) as dist_ct, min(age) as min_val, avg(age) as avg_val, max(age) as max_val from passengers
union all
select 'fare', count (distinct fare), min(fare), avg(fare), max(fare) from passengers;

Ersetzen oder ignorieren?
Es gibt zwei Möglichkeiten mit fehlenden (Null-)Daten umzugehen. Entweder Sie mit etwas Sinnvollerem zu ersetzen oder zu ignorieren, z.B. mit der WHERE [age] IS NOT NULL-Anweisung. Ich habe mich entschieden, die Auswirkungen zu überprüfen, wenn ich die fehlenden Werte durch das Durchschnittsalter ersetzte, das auf dem Wert Titel basiert, der Teil der Spalte [first?name] ist. Dies erscheint ein guter Weg zu sein, um eine ausreichend detaillierte Zahl zum Ersetzten der fehlenden Werte zu erhalten. Dazu muss jedoch der Titel aus der Spalte [first_name] extrahiert werden.
​
Damit habe ich einen view (age_replaced) erstellt, um die zugehörigen Daten zu sammeln:
-- drop view age_replaced;
-- average age by title
-- create view age_replaced as
with CTE1 as
(
select
passengerID,
trim(substring(first_name,1,charindex('.',first_name,1))) as title,
first_name,
last_name,
age
from passengers p
)
,
CTE2 as
(
select
title,
avg(age) as avg_age
from
(
SELECT
[first_name],
trim(substring(first_name,1,charindex('.',first_name,1))) as title,
[sex],
[age]
FROM [titanic].[dbo].[passengers]
) sub1
group by title
)
select
t1.passengerID,
t1.title,
t1.first_name,
t1.last_name,
t1.age,
cast(
-- here the values are replaced
case
when t1.age <1 then 1
when t1.age is null then t2.avg_age
else t1.age
end
as decimal(6,2)
) as replacedage,
t2.avg_age
from CTE1 t1
inner join CTE2 t2 on t1.title = t2.title;

select
p.sex,
AVG(p.age) as avg_original,
avg(replacedage) as avg_replaced,
AVG(p.age) - avg(replacedage) as avg_difference
from passengers p
join [dbo].[age_replaced] r on r.passengerid = p.passengerID
group by p.sex;

Offensichtlich hat das Ersetzen der fehlenden Werte durch das Durchschnittsalter gemäß dem Titel der Passagiere keine signifikanten Auswirkung, daher habe ich mich entschieden, mit der bestehenden Altersgruppierung fortzufahren und die fehlenden Werte zu ignorieren (263 Nullzeilen von 1306).
Mit dem Ergebnis und der Datenqualität bin ich bisher zufrieden, jetzt tauchen wir in die Analyse ein!
Jetzt kann ich das Query zum Vergleich der Daten auf dem view laufen lassen.
Analyse der Überlebensrate
Die Überlebensrate gesamt
Der allgemeine Ansatz zur Berechnung der Überlebensrate basiert auf einem Subquery mit WINDOW-Funktionen, um den Divisor gruppiert nach der gewünschten Dimension gruppiert zu erhalten. Dieses Schema wiederholt sich für die folgenden Analysen in ähnlicher Weise.
/* overall survival */
select
t1.survived,
count(*) as num_of_pass,
max(t1.total_passengers) as total_pass,
format(round(1.00 * count(*) / max(t1.total_passengers),4),'P') as survival_rate,
replicate('|',floor(100 * count(*) / max(t1.total_passengers))) as visual
from
-- subquery for the overall number of passengers
(
select
count(*) over () as total_passengers, passengers.*
from passengers
) t1
group by t1.survived; -- group by the code survived (1 or 0)

Tragischerweise überlebten fast zwei Drittel der Passagiere nicht, nur 500 von 1306 wurden gerettet. Lassen Sie uns nun etwas tiefer graben, um zu verstehen, wie die Überlebenden nach Klasse, Geschlecht, Alter usw. verteilt sind.
Überlebende nach Geschlecht und Alter
Wie wir alle wissen, hatten Frauen eine deutlich höhere Überlebenschance als Männer. Die Analyse bestätigt, dass weibliche Passagiere in allen Klassen eine Überlebensrate von 72,7 % hatten, während 4 von 5 Männern starben. Mit GROUP BY ROLLUP habe ich Zwischensummen nach Geschlecht berechnet.
/* survial rate by sex and class */
with CTE1 as
(
select
t1.pclass as pclass,
case
when t1.pclass = 1 then 'First'
when t1.pclass = 2 then 'Second'
when t1.pclass = 3 then 'Third'
else 'False'
end as class,
t1.sex as sex,
-- count(*) as num_of_pass_by_pclass,
max(total_passengers) as all_passengers,
sum(survived) as survived
from
(
select
passengerid as pass_id,
pclass as pclass,
sex as sex,
survived as survived,
count(*) over (partition by pclass, sex) as total_passengers
from passengers
) t1
where survived = 1
group by sex, pclass
)
select
sex,
class,
sum(all_passengers) as all_passengers,
sum(survived) as survived,
cast((100.00 * sum(survived)) / sum(all_passengers) as decimal(5,1)) as 'survived %',
100-cast((100.00 * sum(survived)) / sum(all_passengers) as decimal(5,1)) as 'died %'
from CTE1
group by rollup (sex, class)
;

Überleben nach Geschlecht
Das Durchschnittsalter der Passagiere
Werfen wir einen Blick auf das Durchschnittsalter nach Klasse und Geschlecht; nur aus Neugier und um die PIVOT-Anweisung in SQL zu üben ;-).
select * from
(
select
pclass,
sex,
age
from passengers
) t
PIVOT(
avg(age)
for sex IN ([female], [male])
) as pvt
order by pclass
;

Die Passagiere in der dritten Klasse waren deutlich jünger als in der ersten Klasse (15 Jahre jünger für Frauen und 16 jünger Jahre für Männer). Hatten die Jüngeren und Fitteren eine bessere Überlebenschance?
Die Altersgruppen berechnen
Die Spalte [Alter] hat 99 verschiedene Werte, daher macht es wenig Sinn, die Überlebensrate für jeden von diesen zu berechnen. Zweckmäßiger ist der Ansatz, [Alter] in Bins zu gruppieren. Ich habe dazu die folgende Hilfstabelle erstellt:
-- drop table age_ranges;
​
/* create a table age_ranges */
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='age_ranges' and xtype='U')
create table age_ranges (agegrp int primary key, agegroup varchar(20), age_from int, age_to int) ;
insert into age_ranges
values
(1,'under 10',0,10),
(2,'between 10 and 20',10,20),
(3,'between 20 and 30',20,30),
(4,'between 30 and 40',30,40),
(5,'between 40 and 50',40,50),
(6,'over 50',50,99),
(7,'missing value',null,null);
select * from age_ranges;
-- truncate table age_grouping;

Diese Abfrage verknüpft die Hilfsbereichstabelle mit der Faktentabelle
Auf diese Weise wird jedem Passagier eine Altersklasse zugeordnet
select
passengerid,
age,
r.agegroup
from passengers p
inner join age_ranges r on p.age >= r.age_from and p.age < r.age_to
order by p.age;

Überleben nach Altersgruppe
Nun ist es ziemlich einfach, die Überlebensrate pro Altersgruppe zu berechnen. Zu beachten ist, dass die Gesamtzahl der Passagiere in Altersklassen nur 1046 beträgt, die Anzahl der Überlebenden nur 427 statt 500, da Nullwerte in der Spalte [age] nicht berücksichtigt wurden (263 Nullen).
select
agegrp,
agegroup,
count(agegrp) as count_by_age_group,
format((1.00 * count(agegrp) / max(all_passengers)),'P') as bin_percent,
sum(survived) as count_survived,
format((1.00 * sum(survived) / count(agegrp)),'P') as percent_survived
from (
select
passengerID,
age,
survived,
r.[agegrp],
r.[agegroup],
count(passengerid) over() as all_passengers
from passengers p
/* join with the age ranges table */
inner join age_ranges r on p.age >= r.age_from and p.age < r.age_to
) sub1
group by agegrp,agegroup
order by agegrp;

Säuglinge und Kinder unter 10 Jahren bildeten die kleinste Gruppe (7,8 %), ihre Überlebenschance war jedoch fast doppelt so hoch (61 %) im Vergleich zu Erwachsenen, die eine Überlebensrate zwischen 36 und 42 % hatten.
Alles verbinden (Klasse, Geschlecht, Alter)
Nun sehen wir die Ergebnisse nach Altersklasse, Geschlecht und Klasse.
with CTE1 as
(
select
passengerID,
pclass,
sex,
age,
survived,
case when age <= 10 then 1
when age > 10 and age <= 20 then 2
when age > 20 and age <= 30 then 3
when age > 30 and age <= 40 then 4
when age > 40 and age <= 50 then 5
when age > 50 then 6
else 7
end as agesegm
from passengers
where age is not null
)
select
c.pclass,
c.sex,
t.agegroup,
count(passengerid) as no_of_passenger,
sum(c.survived) as survived,
format(1.00 * sum(c.survived) / count(passengerid),'P') as survicedpercent
-- replicate('|',floor(100 * sum(v.survived) / count(passengerid))) as visual
from CTE1 c
inner join age_grouping t on t.agegrp = c.agesegm
group by c.pclass,c.sex, c.agesegm,t.agegroup
order by c.pclass,c.sex, c.agesegm
;
Die Ergebnisliste hat 36 Zeilen (3 Klassen, 2 Geschlechter, 6 Altersgruppen), wovon ich hier nur die ersten 10 Zeilen zeige. Es fällt eine sehr interessante Tatsache auf, wenn wir die Daten visualisieren (Entschuldigung, dafür habe ich Power BI verwendet, da SQL wirklich nicht die besten Visualisierungskapazitäten hat :-).
​
​Die männlichen Passagiere der zweiten Klasse hatten eine noch schlechtere Überlebenschance als die in der dritten Klasse (mit Ausnahme der Kleinkinder): Nur 5 von 147 Männern überlebten die Überfahrt in der zweiten Klasse (3,4%), die Überlebensrate in der dritten Klasse für diese Passagiergruppen waren 15,1 % und 33,8 % in der ersten Klasse.


Die 10 besten Überlebensgruppen
Ok, das sind viele Zahlen. Wie wäre es, wenn wir uns die Top 10 Gruppen nach Klasse, Geschlecht und Alter ansehen, um zu sehen, wer die besten Chancen hatte.
select top 10
a.pclass,
a.sex,
ag.agegroup,
count(pclass) as no_of_passenger,
sum(survived) as survived,
format(1.00 * sum(survived) / count(passengerid),'P') as survicedpercent
from v_agegrouping a (I used a view here to make my life easier)
inner join age_grouping ag on ag.agegrp = a.agesegm
group by a.pclass,ag.agegroup,a.sex
order by (1.00 * sum(survived) / count(passengerid) ) desc, pclass
;

Es überrascht nicht, dass die 10 besten Überlebensgruppen hauptsächlich Frauen sind, tatsächlich 8 von 10, während männliche Säuglinge die beiden verbleibenden Gruppen von Überlebenden in den Top 10 ausmachten. Nur 14 männliche Passagiere sind in den 10 besten Überlebensgruppen, aber 159 weibliche.
Und da ist noch mehr:
Solo oder mit Familie: wer hat überlebt?
Um diese Frage zu beantworten, müssen wir uns die Informationen zu Geschwistern oder Ehepartnern [sibsp] bzw. Eltern oder Kindern [parch] sowie die Spalten [ticket] und [last_name] ansehen, um herauszufinden, wer Alleinreisende(r) oder Familienmitglied ist . Dabei zeigen sich einige Herausforderungen:
In der ersten Klasse reisten zum Beispiel Familien oft mit ihren Angestellten/Bediensteten auf dem lgeichen Ticket (Beispiel A), aber diese Passagiere gehören nicht zur Familie, die Spalten [sibsp] und [parch] sind. Sollen also Passagiere, die als Personal reisten als Alleinreisende gelten? In der dritten Klasse gibt es ebenfalls Passagiere, die auf dem gleichen Ticket reisten, aber wahrscheinlich als Alleinreisende zu betrachten sind (Beispiel B). Es gibt jadoch auch Familien und Paare, die aus irgendwelchen Günden mit unterschiedlichen Tickets reisen, aber nicht als sibsp gekennzeichnet sind, wie das Beispiel C zeigt.
Beispiel A

Beispiel B

Beispiel C

Die Passagier-Segmentierung erstellen
Schließlich entschied ich mich für die folgende Segmentierung. Das offensichtlichste Attribut für eine Familienzugehörigkeit besteht in dem Wert in der Spalte [sibsp] oder [parch]; diese habe ich als "Familiengröße" bezeichnet. Passagiere mit einer Familiengröße von 0, einer lastname_ticket-Zählung (partition by last_name and ticket) von 1 und einer Ticketzählung von 1 gelten als Alleinreisende. Die restlichen Passagiere werden als "unklarer Status" eingestuft, dies sind 127 Passagiere. Ich nenne diese Segmentierung [fam_group].
/* calculate the familysize3 */
with CTE1 as
(
select
passengerID,
survived,
pclass,
last_name,
first_name,
ticket,
cabin,
sex,
age,
sibsp,
parch,
familysize,
lastnamecount,
-- lastnamerow,
lastnameticketcount,
ticketcount,
ticketrow
from
(
select
*,
sibsp + parch as familysize, -- simply add sibsp and parch to determine, whether there is a value > 0
count(last_name) over(partition by last_name) as lastnamecount, -- count the occurance of name by last_name
-- ROW_NUMBER() over(partition by last_name order by last_name) as lastnamerow, -- the last name in order of their occurance
count(last_name) over(partition by last_name, ticket) as lastnameticketcount, -- count the name by last_name and ticketnumber grouping
count(ticket) over(partition by ticket) as ticketcount, -- number of tickets count by the ticket number
ROW_NUMBER() over(partition by ticket order by ticket) as ticketrow -- count of ticket number in order of their occurance
from passengers
) t1
)
select * from
(
Select *,
case
when familysize > 0 then 'Rel' -- here they are or having relatives of some kind -> means family
when familysize = 0 and lastnameticketcount = 1 and ticketcount = 1 then 'Solo' -- the familysize is 0 and there is one ticket per name -> solo
else 'unclear'
end fam_group
from CTE1
) t2
order by last_name,ticket,ticketrow;

Mal sehen, ob das die Frage beantwortet. Die Segmentierung in eine CTE einfügen und die Analyse laufen lassen:
Sie zeigt, dass bei Passagieren mit Verwandten (Ehepartner oder Kinder) die Überlebenschancen für männliche (16,4 % ↗ 25,9 %) und weibliche (66,9 % ↗ 72,4 %) Passagiere im Vergleich zu Alleinreisenden leicht erhöht ist. Es ist zu vermuten, dass Familienmitglieder eher einen gemeinsamen Platz in einem der Rettungsbote bekamen als Soloreisende.

Und zu guter Letzt: der Einschiffungshafen
Die Überlebensrate nach Einschiffungshafen ist ein perfektes Beispiel dafür, dass man manchmal zweimal hinschauen muss, um die Zahlen richtig zu interprätieren.
select
t1.embarked,
max(pass_embarked) as allpassengers,
sum(survived) as survived,
100.00 * sum(survived) / max(pass_embarked) as survival_rate
from
(
select
passengerid as pass_id,
embarked,
survived,
count(*) over (partition by embarked) as pass_embarked
from passengers
where embarked is not null -- two passengers have no embarked code
) t1
where survived = 1
group by t1.embarked;

Es zeigt, dass die Überlebenschancen bei der Einschiffung in Cherbourg deutlich höher zu sein scheinen als bei einer Reise von Queenstown oder Southampton. Das macht natürlich allein keinen Sinn, also muss es eine zugrunde liegende Information geben, die dieses Ergebnis verursacht. Eine Erklärung kann in einer unterschiedlichen Verteilung der Passagiere liegen, zum Beispiel nach ihrer Passagierklasse oder der Kabine, in der sie bereisten. Da wir keine konsistenten Informationen über die Kabine haben, werfen wir einen Blick auf die Reiseklasse.
-- the number of passengers embarked
with CTE1 as
(
select
embarked,
pclass,
count(passengerid) as passengercount,
100.00 * count(passengerid) / max(no_pass_emb) as perc_emb_class,
100.00 * count(passengerid) / max(pass_all) as perc_pass_all
from
(
select
passengerid,
pclass,
embarked,
count(passengerid) over(partition by embarked) as no_pass_emb,
count(*) over () as pass_all
from passengers
where embarked is not null
) sub1
group by embarked,pclass
)
,
-- survival rate by embarked
CTE2 as
(
select
embarked,
max(pass_embarked) as embarkedpassengers,
sum(survived) as survived,
100.00 * sum(survived) / max(pass_embarked) as survival_rate
from
(
select
passengerid as pass_id,
embarked,
survived,
count(*) over (partition by embarked) as pass_embarked
from passengers
where embarked is not null
) sub2
where survived = 1
group by embarked
)
-- select * from CTE2;
-- return information
select
case
when c1.embarked = 'C' then 'Cherbourg (FRA)'
when c1.embarked = 'Q' then 'Queenstown (IRL)'
when c1.embarked = 'S' then 'Southampton (ENG)'
else 'missing'
end as embarked_harbour,
c1.pclass,
c1.passengercount,
convert(decimal(6,1),c1.perc_pass_all) as all_pass_perc,
convert(decimal(6,1),c1.perc_emb_class) as embarked_pass_perc,
convert(decimal(6,1),c2.survival_rate) as embarked_survival_perc
from CTE1 c1
inner join CTE2 c2 on c2.embarked=c1.embarked
;

Die Antwort liegt in der Passagierverteilung nach Passagierklasse. Die höhere Überlebensrate für die Passagiere von Cherbourg liegt in der proportional höheren Anzahl von Passagieren, die in der ersten Klasse reisten (52,2%); wie wir wissen, war die Überlebensrate in der ersten Klasse deutlich höher als in der zweiten oder dritte Klasse. Im Gegensatz dazu reisten 92 % der Passagiere von Queenstown in Klasse 3 mit einer viel geringeren Überlebenschance, ebenso wie von Southampton, wo 52 % der Passagiere in der dritten Klasse reisten.
/* calculate the survival rate by family group */
select
t1.fam_group,
sex,
-- count(*) as num_of_pass_by_famgroup,
max(total_passengers) as allpassengers,
sum(survived) as survived,
format(round(sum(survived) / cast(max(total_passengers) as float),4),'P') as survival_rate
from
(
select
passengerid as pass_id,
fam_group,
survived,
sex,
count(*) over (partition by fam_group, sex) as total_passengers
from CTE2
) t1
where survived = 1
group by t1.fam_group, t1.sex;
und schließlich ...
Mit diesem Beitrag möchte ich nicht nur die Verwendung von SQL zur Datenanalyse darstellen, ich empfinde es auch als spannend und befriedigend, Einblicke in die menschlichen Schicksal bei einer der größten Passagierschiffkatastrophen der Geschichte zu erhalten.
​
Und natürlich bin ich nicht der Erste, der die Titanic-Katastrophe mit SQL analysiert, dieser Artikel wurde von Do Lee und seinem Artikel auf Kaggle inspiriert Kaggle Titanic Competition in SQL | by Do Lee | Towards Data Science.