Graficar datos
En el último episodio utilizamos un proveedor de tipo CSV para obtener un conjunto de datos interesantes sobre planetas fuera de nuestro sistema sola r.
Tracemos esos datos.
Aquí está Plotly
Usaremos Plotly como biblioteca de trazado.
Puedes consultar la documentación aquí, ¡está llena de ejemplos!
La figura en Plotly se define como un tipo de unión discriminada:
type GenericChart =
| Chart of Trace * Layout * Config * DisplayOptions
| MultiChart of Trace list * Layout * Config * DisplayOptions
Tenga en cuenta que el gráfico MultiChar
(es decir, un gráfico con diferentes conjuntos o Traces) es igual que un único Chart
, excepto que contiene una Trace list
en lugar de un único Trace
.
De los documentos:
Trace
es, en principio, la representación de un conjunto de datos en un gráfico, que incluye los datos en sí, el color y la forma de la visualización, etc.Layout
es todo lo que hay en el gráfico que no es específico del conjunto de datos, p. la forma y el estilo de los ejes, el título del gráfico, etc.Config
es un objeto que configura propiedades de alto nivel del gráfico, como hacer que todos los elementos del gráfico sean editables o la barra de herramientas en la parte superior.DisplayOptions
es un objeto que contiene metainformación sobre cómo funciona el documento html que contiene el gráfico.
La biblioteca de gráficos Plotly está adaptada a la web y está especialmente diseñada para que los gráficos estén disponibles en ella sin problemas. Como tal, genera un formato HTML, con mucha interactividad.
#r "nuget: Plotly.NET, 4.2.0"
#r "nuget: Plotly.NET.Interactive, 4.2.0"
- Plotly.NET, 4.2.0
- Plotly.NET.Interactive, 4.2.0
Loading extensions from `/Users/flavioc/.nuget/packages/plotly.net.interactive/4.2.0/lib/netstandard2.1/Plotly.NET.Interactive.dll`
open Plotly.NET
Comencemos simplemente trazando algunos puntos:
let x = [1;2;5]
let y = [3;4;2]
Chart.Point(x,y)
Chart.Line(x,y)
¿Qué tal trazar varios grupos de puntos?
let x = [1;2;3]
let y1 = [3;4;2]
let y2 = [5;6;4]
[Chart.Point(x, y1);
Chart.Point(x, y2)]
|> Chart.combine
¡Hermoso! Entonces, la figura de los dos conjuntos es solo la combinación de los conjuntos en una lista de gráficos (ahora es el momento de preguntarse por qué la programación funcional es tan buena…).
Censo de exoplanetas
Nos gustaría poder reproducir el gráfico del Censo de exoplanetas que se puede encontrar en Sitio de la NASA sobre exoplanetas:
Como puedes ver, es una gráfica de las masas de los planetas respecto al período orbital, con una escala logarítmica en ambos ejes. Además, el color diferencia el método utilizado para detectar el planeta. Por otra parte, hay un control deslizante en la parte inferior que filtra los planetas descubiertos hasta un año determinado.
Todavía necesitamos un poco de procesamiento de nuestros “planetas” para sintetizar los datos para graficar. Necesitamos construir nuestros datos x e y para cada tipo de planeta y luego dividirlos según el método de detección. Las coordenadas de las abscisas vienen dadas por la propiedad .Pl_orbper
, mientras que nuestras y son las de .Pl_masse
. También debemos realizar un seguimiento del método de detección, .Discoverymethod
. Finalmente, dado que la figura varía según la posición del control deslizante que representa el año del descubrimiento, también necesitaríamos capturar esos datos.
Usemos el Type Provider
con los datos consolidados que obtuvimos en el episodio anterior:
#r "nuget: FSharp.Data"
open FSharp.Data
- FSharp.Data, 6.4.0
[<Literal>]
let exoplanetsFile = "../data/consolidatedExoplanets.csv"
type ExoTypeProvider = FSharp.Data.CsvProvider<exoplanetsFile, HasHeaders=true, PreferOptionals=true>
let planets = ExoTypeProvider.GetSample()
Obteniendo el año del descubrimiento
Nuestro proveedor de tipos nos proporciona el período orbital, la masa y el método de descubrimiento directamente en algunos de los campos del tipo: .Pl_orbper
, .Pl_masse
, .Discoverymethod
respectivamente. ¿Dónde está el año en nuestros datos primitivos? Inspeccionemos la primera entrada y veamos qué obtenemos:
planets.Rows |> Seq.item 0
(OGLE-TR-10 b, Published Confirmed, <a refstr=KONACKI_ET_AL__2005 href=https://ui.adsabs.harvard.edu/abs/2005ApJ...624..372K/abstract target=ref> Konacki et al. 2005 </a>, , Some(222.47977142857142857142857143), Some(3.1013125714285714285714285714), Transit, False, Some(1344.9700000), )
Item1 | OGLE-TR-10 b | ||||||||
Item2 | Published Confirmed | ||||||||
Item3 | <a refstr=KONACKI_ET_AL__2005 href=https://ui.adsabs.harvard.edu/abs/2005ApJ...624..372K/abstract target=ref> Konacki et al. 2005 </a> | ||||||||
Item4 | <null> | ||||||||
Item5 |
|
Value | 222.47977142857142857142857143 |
Some(3.1013125714285714285714285714)
Value | 3.1013125714285714285714285714 |
Transit
(False, Some(1344.9700000), )
Item1 | False | ||
Item2 |
|
Value | 1344.9700000 |
<null>
Parece que el año está incluido en la información de la publicación. En este caso, está contenido en la string
html:
<a refstr=KONACKI_ET_AL__2005 href=https://ui.adsabs.harvard.edu/abs/2005ApJ...624..372K/abstract target=ref> Konacki et al. 2005 </a>
Puede que la forma más sencilla de recuperar el año sea dividir la cadena por espacios y usar expresiones regulares para obtener la cadena que representa un año (es decir, una cadena de cuatro caracteres que contiene solo números). Para ello construimos una función que dada la referencia html, devuelve el año
let getYearOfDiscovery (ref: string) =
let found = RegularExpressions.Regex.Matches(ref, " \d{4}")
match found.Count with
| 0 -> None
| 1 -> Some (found.[0].Value.Trim(' ') |> int)
| _ -> failwith "More than one year found"
Estamos utilizando aquí todo el poder de .NET a través de RegularExpressions.Regex
. Buscamos en la cadena un número de cuatro dígitos precedido por un espacio con la expresión regular ` \d{4}`. Si bien esto parece bastante aventurado, una inspección rápida de los datos nos demuestra que tenemos razón. Es hora de agradecer a la NASA por brindarnos datos tan uniformes. En otros casos uno podría tener menos suerte…
Probémoslo en el ejemplo:
let ref = "<a refstr=KONACKI_ET_AL__2005 href=https://ui.adsabs.harvard.edu/abs/2005ApJ...624..372K/abstract target=ref> Konacki et al. 2005 </a>"
getYearOfDiscovery ref
Some(2005)
Value | 2005 |
Comprobemos si cada exoplaneta tiene un año de descubrimiento dentro de la referencia. Para ello, obtenemos todos los años de descubrimiento recorriendo el conjunto completo de datos. Luego, podemos dividir el resultado con List.partition
en una lista de aquellos exoplanetas con un año de descubrimiento y aquellos sin él:
let years =
planets.Rows
|> Seq.map (fun row -> row.Disc_refname)
|> Seq.map (fun ref -> getYearOfDiscovery ref,ref)
let exoWithYears, exoWithoutYears =
years
|> Seq.toList
|> List.partition (fun (y,r) -> y.IsSome) // partition into ones that have a year and ones that don't
exoWithoutYears.Length
4
Entonces hay algunos casos (cuatro en realidad) que no tienen un año:
exoWithoutYears.DisplayTable()
Item1 | Item2 |
<null> | <a refstr=Q1_Q12_KOI_TABLE href=https://exoplanetarchive.ipac.caltech.edu/docs/Kepler_KOI_docs.html target=ref>Q1-Q12 KOI Table</a> |
<null> | <a refstr=Q1_Q12_KOI_TABLE href=https://exoplanetarchive.ipac.caltech.edu/docs/Kepler_KOI_docs.html target=ref>Q1-Q12 KOI Table</a> |
<null> | <a refstr=Q1_Q12_KOI_TABLE href=https://exoplanetarchive.ipac.caltech.edu/docs/Kepler_KOI_docs.html target=ref>Q1-Q12 KOI Table</a> |
<null> | <a refstr=Q1_Q12_KOI_TABLE href=https://exoplanetarchive.ipac.caltech.edu/docs/Kepler_KOI_docs.html target=ref>Q1-Q12 KOI Table</a> |
Según la documentación, se trata de tablas de objeto de interés Kepler, es decir, eventos descubiertos que pueden considerarse analizados preliminarmente pero no constituyen exoplanetas confirmados. Dado que nuestra función getYearOfDiscovery
devuelve un valor de Option
, podemos usar Seq.choose
para eliminarlos adecuadamente del conjunto.
Filtrar los datos
Ahora tenemos todas las herramientas para recopilar los datos de nuestro gráfico. Para ello es útil definir nuestro propio tipo para acotar los datos con los que estamos tratando:
type ExoCensusData =
{
DiscoveryMethodName : string
OrbitTimes: decimal // decimal asegura al menos 28 bits, y es un formato _decimal_, no binario
Masses: decimal
YearOfDiscovery: int
}
y crear una secuencia de ellos. Primero, usamos una tupla para guardar los datos intermedios de la tabla, ya que hay algunos valores de opción
, y luego los filtramos a nuestro ExoCensusData
:
let data =
planets.Rows
|> Seq.map (fun row -> (row.Discoverymethod, row.Pl_orbper, row.Pl_masse, getYearOfDiscovery row.Disc_refname))
|> Seq.choose (fun (method, period, mass, year) ->
match period,mass,year with
| Some p, Some m, Some y -> Some {DiscoveryMethodName = method; OrbitTimes = p; Masses = m; YearOfDiscovery = y}
| _ -> None)
Aquí, pasamos una función a Seq.choose
que mantiene solo los elementos que tienen el valor Some
en todos los campos opcionales, .Pl_orbper
, Pl._masse
y año de descubrimiento.
data.DisplayTable()
DiscoveryMethodName | OrbitTimes | Masses | YearOfDiscovery |
Transit | 3.1013125714285714285714285714 | 222.47977142857142857142857143 | 2005 |
Imaging | 73275.446666666666666666666667 | 3000.00000 | 2008 |
Transit | 12.33334285 | 271.05000 | 2012 |
Transit | 6.02978806 | 3.78000 | 2014 |
Transit | 11.578912324 | 19.80000 | 2014 |
Transit | 10.558297226 | 14.70000 | 2014 |
Radial Velocity | 3185.41500000 | 7838.32346 | 2012 |
Transit | 3.89905200 | 271.74465 | 2020 |
Transit | 3.1186028214285714285714285714 | 297.603516 | 2009 |
Transit | 2.1751799028571428571428571429 | 292.1623025 | 2012 |
Transit | 3.0395806166666666666666666667 | 200.86409333333333333333333333 | 2011 |
Transit | 3.474474166 | 237.734225 | 2011 |
Transit | 1.9550955128571428571428571429 | 480.45875 | 2011 |
Transit | 2.7908485271428571428571428571 | 676.97790 | 2015 |
Transit | 3.752094845 | 176.51058833333333333333333333 | 2009 |
Transit | 3.6528224283333333333333333333 | 180.41896 | 2010 |
Transit | 3.76483029 | 176.203472 | 2010 |
Transit | 2.2783146666666666666666666667 | 139.84520 | 2019 |
Transit | 4.2041948666666666666666666667 | 292.72143 | 2019 |
Transit | 2.484194492 | 340.545795 | 2013 |
(1474 more) |
Graficando todos los datos
Ahora tenemos nuestros datos muy bien distribuidos en una secuencia de elementos del tipo ExoCensusData
. Comencemos graficando todos los datos primero, para ver cómo funciona el formato de la figura, y agregaremos el control deslizante después.
Recuerde que para usar la función Chart.Point
, necesitamos tener nuestras x
e y
para cada traza juntas. Además, necesitamos tener un trace para cada tipo de descubrimiento. Vayamos paso a paso, primero agrupando nuestros datos por método de descubrimiento.
let dataByDiscoveryMethod =
data
|> Seq.groupBy (fun exoData -> exoData.DiscoveryMethodName)
y luego creando los datos de la figura con un tipo de ayuda:
type ExoTraces =
{
DiscoveryMethodName : string
OrbitTimes : seq<decimal>
Masses : seq<decimal>
}
let exoTraces =
dataByDiscoveryMethod
|> Seq.map (fun (method, data) ->
let orbits = data |> Seq.map (fun exoData -> exoData.OrbitTimes)
let masses = data |> Seq.map (fun exoData -> exoData.Masses)
{DiscoveryMethodName = method; OrbitTimes = orbits; Masses = masses})
Solo necesitamos mapear nuevamente desde exoTraces
a Chart.Points
y Chart.Combine
exoTraces
|> Seq.map (fun exo -> Chart.Point(exo.OrbitTimes,exo.Masses))
|> Chart.combine
¡Hermoso! Ahora podemos ver por qué necesitamos una escala logarítmica. Para hacer eso usaremos un objeto de Layout
de Plotly
:
open Plotly.NET.LayoutObjects // this namespace contains all object abstractions for layout styling
Se definen dos valores LinearAxis
, uno para cada eje:
let orbPeriodAxis =
LinearAxis.init (
Title = Title.init (Text = "ORBIT PERIOD (EARTH DAYS)"),
AxisType = StyleParam.AxisType.Log,
ShowLine = true,
ShowGrid = false,
Range = StyleParam.Range.MinMax (-2, 8),
Ticks = StyleParam.TickOptions.Outside
)
let massLogAxis =
LinearAxis.init (
Title = Title.init (Text = "PLANET MASS (EARTH MASSES)"),
AxisType = StyleParam.AxisType.Log,
ShowLine = true,
ShowGrid = false,
Ticks = StyleParam.TickOptions.Outside
)
Establecemos la escala logarítmica para ambos ejes con AxisType = StyleParam.AxisType.Log
. Además, fijamos el Rango para el eje x (el período orbital) con Range
. Tenga en cuenta que, dado que el eje está en escala logarítmica, el rango StyleParam.Range.MinMax (-2, 8)
va desde 0,01 ($10^{-2}$) hasta 100 millones ($10^{8}$). Todos los demás argumentos se explican por sí solos.
exoTraces
|> Seq.map (fun exo -> Chart.Point(exo.OrbitTimes,exo.Masses))
|> Chart.combine
|> Chart.withXAxis orbPeriodAxis
|> Chart.withYAxis massLogAxis
Abramos los círculos de la figura.
let openCircle =
StyleParam.MarkerSymbol.Modified(
StyleParam.MarkerSymbol.Circle,
StyleParam.SymbolStyle.Open
)
Y podemos usar el argumento Name
para cambiar el nombre de las leyendas de cada rastro por el nombre del método de descubrimiento que ya tenemos en nuestro tipo:
exoTraces
|> Seq.map (fun exo ->
Chart.Point(exo.OrbitTimes,exo.Masses, Name = exo.DiscoveryMethodName)
|> Chart.withMarkerStyle(Symbol=openCircle))
|> Chart.combine
|> Chart.withXAxis orbPeriodAxis
|> Chart.withYAxis massLogAxis
¡Cambiemos la relación de aspecto y ya casi terminamos! Para hacer eso, necesitamos usar un ConfigObject
:
open Plotly.NET.ConfigObjects
let layout =
Layout.init(
Width = 1000,
Height = 500
)
exoTraces
|> Seq.map (fun exo ->
Chart.Point(exo.OrbitTimes,exo.Masses, Name = exo.DiscoveryMethodName)
|> Chart.withMarkerStyle(Symbol=openCircle))
|> Chart.combine
|> Chart.withXAxis orbPeriodAxis
|> Chart.withYAxis massLogAxis
|> Chart.withLayout layout
¡Listo! En la siguiente parte, agregaremos el control deslizante…