1
0
mirror of https://github.com/microsoft/qlib.git synced 2026-07-13 07:46:53 +08:00

Plot enhancement (#1390)

* horizontally put the bar figures

* 1) use rangebreaks to handle gaps in datetime axis instead of make them string; 2) allow simultaneously plot rankic in ic_figure

* pylint improvement

* fix black lint

* better axis formatting

* default not show gaps

* resolve doc built error

* fix pylint

* Update qlib/contrib/report/analysis_model/analysis_model_performance.py

More detailed description

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>

* Update qlib/contrib/report/analysis_model/analysis_model_performance.py

for Python backward compatibility

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>

* add doc string

* fix black

* 1) limit numpy version as numba support for 1.24+ has not been released; 2) no need to use custom numba version for pytest.

Co-authored-by: you-n-g <you-n-g@users.noreply.github.com>
This commit is contained in:
YQ Tsui
2022-12-31 07:58:41 +08:00
committed by GitHub
parent 756bd0f65b
commit 2f5ce3dc01
5 changed files with 95 additions and 43 deletions

View File

@@ -1,5 +1,6 @@
# Copyright (c) Microsoft Corporation. # Copyright (c) Microsoft Corporation.
# Licensed under the MIT License. # Licensed under the MIT License.
from functools import partial
import pandas as pd import pandas as pd
@@ -10,7 +11,11 @@ import matplotlib.pyplot as plt
from scipy import stats from scipy import stats
from typing import Sequence
from qlib.typehint import Literal
from ..graph import ScatterGraph, SubplotsGraph, BarGraph, HeatmapGraph from ..graph import ScatterGraph, SubplotsGraph, BarGraph, HeatmapGraph
from ..utils import guess_plotly_rangebreaks
def _group_return(pred_label: pd.DataFrame = None, reverse: bool = False, N: int = 5, **kwargs) -> tuple: def _group_return(pred_label: pd.DataFrame = None, reverse: bool = False, N: int = 5, **kwargs) -> tuple:
@@ -48,12 +53,13 @@ def _group_return(pred_label: pd.DataFrame = None, reverse: bool = False, N: int
t_df["long-average"] = t_df["Group1"] - pred_label.groupby(level="datetime")["label"].mean() t_df["long-average"] = t_df["Group1"] - pred_label.groupby(level="datetime")["label"].mean()
t_df = t_df.dropna(how="all") # for days which does not contain label t_df = t_df.dropna(how="all") # for days which does not contain label
# FIXME: support HIGH-FREQ
t_df.index = t_df.index.strftime("%Y-%m-%d")
# Cumulative Return By Group # Cumulative Return By Group
group_scatter_figure = ScatterGraph( group_scatter_figure = ScatterGraph(
t_df.cumsum(), t_df.cumsum(),
layout=dict(title="Cumulative Return", xaxis=dict(type="category", tickangle=45)), layout=dict(
title="Cumulative Return",
xaxis=dict(tickangle=45, rangebreaks=kwargs.get("rangebreaks", guess_plotly_rangebreaks(t_df.index))),
),
).figure ).figure
t_df = t_df.loc[:, ["long-short", "long-average"]] t_df = t_df.loc[:, ["long-short", "long-average"]]
@@ -110,22 +116,36 @@ def _plot_qq(data: pd.Series = None, dist=stats.norm) -> go.Figure:
return fig return fig
def _pred_ic(pred_label: pd.DataFrame = None, rank: bool = False, **kwargs) -> tuple: def _pred_ic(
pred_label: pd.DataFrame = None, methods: Sequence[Literal["IC", "Rank IC"]] = ("IC", "Rank IC"), **kwargs
) -> tuple:
""" """
:param pred_label: :param pred_label: pd.DataFrame
:param rank: must contain one column of realized return with name `label` and one column of predicted score names `score`.
:param methods: Sequence[Literal["IC", "Rank IC"]]
IC series to plot.
IC is sectional pearson correlation between label and score
Rank IC is the spearman correlation between label and score
For the Monthly IC, IC histogram, IC Q-Q plot. Only the first type of IC will be plotted.
:return: :return:
""" """
if rank: _methods_mapping = {"IC": "pearson", "Rank IC": "spearman"}
ic = pred_label.groupby(level="datetime").apply(
lambda x: x["label"].rank(pct=True).corr(x["score"].rank(pct=True))
)
else:
ic = pred_label.groupby(level="datetime").apply(lambda x: x["label"].corr(x["score"]))
_index = ic.index.get_level_values(0).astype("str").str.replace("-", "").str.slice(0, 6) def _corr_series(x, method):
_monthly_ic = ic.groupby(_index).mean() return x["label"].corr(x["score"], method=method)
ic_df = pd.concat(
[
pred_label.groupby(level="datetime").apply(partial(_corr_series, method=_methods_mapping[m])).rename(m)
for m in methods
],
axis=1,
)
_ic = ic_df.iloc(axis=1)[0]
_index = _ic.index.get_level_values(0).astype("str").str.replace("-", "").str.slice(0, 6)
_monthly_ic = _ic.groupby(_index).mean()
_monthly_ic.index = pd.MultiIndex.from_arrays( _monthly_ic.index = pd.MultiIndex.from_arrays(
[_monthly_ic.index.str.slice(0, 4), _monthly_ic.index.str.slice(4, 6)], [_monthly_ic.index.str.slice(0, 4), _monthly_ic.index.str.slice(4, 6)],
names=["year", "month"], names=["year", "month"],
@@ -148,27 +168,27 @@ def _pred_ic(pred_label: pd.DataFrame = None, rank: bool = False, **kwargs) -> t
_monthly_ic = _monthly_ic.reindex(fill_index) _monthly_ic = _monthly_ic.reindex(fill_index)
_ic_df = ic.to_frame("ic") ic_bar_figure = ic_figure(ic_df, kwargs.get("show_nature_day", False))
ic_bar_figure = ic_figure(_ic_df, kwargs.get("show_nature_day", True))
ic_heatmap_figure = HeatmapGraph( ic_heatmap_figure = HeatmapGraph(
_monthly_ic.unstack(), _monthly_ic.unstack(),
layout=dict(title="Monthly IC", yaxis=dict(tickformat=",d")), layout=dict(title="Monthly IC", xaxis=dict(dtick=1), yaxis=dict(tickformat="04d", dtick=1)),
graph_kwargs=dict(xtype="array", ytype="array"), graph_kwargs=dict(xtype="array", ytype="array"),
).figure ).figure
dist = stats.norm dist = stats.norm
_qqplot_fig = _plot_qq(ic, dist) _qqplot_fig = _plot_qq(_ic, dist)
if isinstance(dist, stats.norm.__class__): if isinstance(dist, stats.norm.__class__):
dist_name = "Normal" dist_name = "Normal"
else: else:
dist_name = "Unknown" dist_name = "Unknown"
_ic_df = _ic.to_frame("IC")
_bin_size = ((_ic_df.max() - _ic_df.min()) / 20).min() _bin_size = ((_ic_df.max() - _ic_df.min()) / 20).min()
_sub_graph_data = [ _sub_graph_data = [
( (
"ic", "IC",
dict( dict(
row=1, row=1,
col=1, col=1,
@@ -202,12 +222,13 @@ def _pred_autocorr(pred_label: pd.DataFrame, lag=1, **kwargs) -> tuple:
pred = pred_label.copy() pred = pred_label.copy()
pred["score_last"] = pred.groupby(level="instrument")["score"].shift(lag) pred["score_last"] = pred.groupby(level="instrument")["score"].shift(lag)
ac = pred.groupby(level="datetime").apply(lambda x: x["score"].rank(pct=True).corr(x["score_last"].rank(pct=True))) ac = pred.groupby(level="datetime").apply(lambda x: x["score"].rank(pct=True).corr(x["score_last"].rank(pct=True)))
# FIXME: support HIGH-FREQ
_df = ac.to_frame("value") _df = ac.to_frame("value")
_df.index = _df.index.strftime("%Y-%m-%d")
ac_figure = ScatterGraph( ac_figure = ScatterGraph(
_df, _df,
layout=dict(title="Auto Correlation", xaxis=dict(type="category", tickangle=45)), layout=dict(
title="Auto Correlation",
xaxis=dict(tickangle=45, rangebreaks=kwargs.get("rangebreaks", guess_plotly_rangebreaks(_df.index))),
),
).figure ).figure
return (ac_figure,) return (ac_figure,)
@@ -233,32 +254,33 @@ def _pred_turnover(pred_label: pd.DataFrame, N=5, lag=1, **kwargs) -> tuple:
"Bottom": bottom, "Bottom": bottom,
} }
) )
# FIXME: support HIGH-FREQ
r_df.index = r_df.index.strftime("%Y-%m-%d")
turnover_figure = ScatterGraph( turnover_figure = ScatterGraph(
r_df, r_df,
layout=dict(title="Top-Bottom Turnover", xaxis=dict(type="category", tickangle=45)), layout=dict(
title="Top-Bottom Turnover",
xaxis=dict(tickangle=45, rangebreaks=kwargs.get("rangebreaks", guess_plotly_rangebreaks(r_df.index))),
),
).figure ).figure
return (turnover_figure,) return (turnover_figure,)
def ic_figure(ic_df: pd.DataFrame, show_nature_day=True, **kwargs) -> go.Figure: def ic_figure(ic_df: pd.DataFrame, show_nature_day=True, **kwargs) -> go.Figure:
"""IC figure r"""IC figure
:param ic_df: ic DataFrame :param ic_df: ic DataFrame
:param show_nature_day: whether to display the abscissa of non-trading day :param show_nature_day: whether to display the abscissa of non-trading day
:param \*\*kwargs: contains some parameters to control plot style in plotly. Currently, supports
- `rangebreaks`: https://plotly.com/python/time-series/#Hiding-Weekends-and-Holidays
:return: plotly.graph_objs.Figure :return: plotly.graph_objs.Figure
""" """
if show_nature_day: if show_nature_day:
date_index = pd.date_range(ic_df.index.min(), ic_df.index.max()) date_index = pd.date_range(ic_df.index.min(), ic_df.index.max())
ic_df = ic_df.reindex(date_index) ic_df = ic_df.reindex(date_index)
# FIXME: support HIGH-FREQ
ic_df.index = ic_df.index.strftime("%Y-%m-%d")
ic_bar_figure = BarGraph( ic_bar_figure = BarGraph(
ic_df, ic_df,
layout=dict( layout=dict(
title="Information Coefficient (IC)", title="Information Coefficient (IC)",
xaxis=dict(type="category", tickangle=45), xaxis=dict(tickangle=45, rangebreaks=kwargs.get("rangebreaks", guess_plotly_rangebreaks(ic_df.index))),
), ),
).figure ).figure
return ic_bar_figure return ic_bar_figure
@@ -272,9 +294,10 @@ def model_performance_graph(
rank=False, rank=False,
graph_names: list = ["group_return", "pred_ic", "pred_autocorr"], graph_names: list = ["group_return", "pred_ic", "pred_autocorr"],
show_notebook: bool = True, show_notebook: bool = True,
show_nature_day=True, show_nature_day: bool = False,
**kwargs,
) -> [list, tuple]: ) -> [list, tuple]:
"""Model performance r"""Model performance
:param pred_label: index is **pd.MultiIndex**, index name is **[instrument, datetime]**; columns names is **[score, label]**. :param pred_label: index is **pd.MultiIndex**, index name is **[instrument, datetime]**; columns names is **[score, label]**.
It is usually same as the label of model training(e.g. "Ref($close, -2)/Ref($close, -1) - 1"). It is usually same as the label of model training(e.g. "Ref($close, -2)/Ref($close, -1) - 1").
@@ -297,17 +320,14 @@ def model_performance_graph(
:param graph_names: graph names; default ['cumulative_return', 'pred_ic', 'pred_autocorr', 'pred_turnover']. :param graph_names: graph names; default ['cumulative_return', 'pred_ic', 'pred_autocorr', 'pred_turnover'].
:param show_notebook: whether to display graphics in notebook, the default is `True`. :param show_notebook: whether to display graphics in notebook, the default is `True`.
:param show_nature_day: whether to display the abscissa of non-trading day. :param show_nature_day: whether to display the abscissa of non-trading day.
:param \*\*kwargs: contains some parameters to control plot style in plotly. Currently, supports
- `rangebreaks`: https://plotly.com/python/time-series/#Hiding-Weekends-and-Holidays
:return: if show_notebook is True, display in notebook; else return `plotly.graph_objs.Figure` list. :return: if show_notebook is True, display in notebook; else return `plotly.graph_objs.Figure` list.
""" """
figure_list = [] figure_list = []
for graph_name in graph_names: for graph_name in graph_names:
fun_res = eval(f"_{graph_name}")( fun_res = eval(f"_{graph_name}")(
pred_label=pred_label, pred_label=pred_label, lag=lag, N=N, reverse=reverse, rank=rank, show_nature_day=show_nature_day, **kwargs
lag=lag,
N=N,
reverse=reverse,
rank=rank,
show_nature_day=show_nature_day,
) )
figure_list += fun_res figure_list += fun_res

View File

@@ -119,7 +119,7 @@ def _get_risk_analysis_figure(analysis_df: pd.DataFrame) -> Iterable[py.Figure]:
_figure = SubplotsGraph( _figure = SubplotsGraph(
_get_all_risk_analysis(analysis_df), _get_all_risk_analysis(analysis_df),
kind_map=dict(kind="BarGraph", kwargs={}), kind_map=dict(kind="BarGraph", kwargs={}),
subplots_kwargs={"rows": 4, "cols": 1}, subplots_kwargs={"rows": 1, "cols": 4},
).figure ).figure
return (_figure,) return (_figure,)

View File

@@ -4,6 +4,7 @@
import pandas as pd import pandas as pd
from ..graph import ScatterGraph from ..graph import ScatterGraph
from ..utils import guess_plotly_rangebreaks
def _get_score_ic(pred_label: pd.DataFrame): def _get_score_ic(pred_label: pd.DataFrame):
@@ -19,7 +20,7 @@ def _get_score_ic(pred_label: pd.DataFrame):
return pd.DataFrame({"ic": _ic, "rank_ic": _rank_ic}) return pd.DataFrame({"ic": _ic, "rank_ic": _rank_ic})
def score_ic_graph(pred_label: pd.DataFrame, show_notebook: bool = True) -> [list, tuple]: def score_ic_graph(pred_label: pd.DataFrame, show_notebook: bool = True, **kwargs) -> [list, tuple]:
"""score IC """score IC
Example: Example:
@@ -53,11 +54,13 @@ def score_ic_graph(pred_label: pd.DataFrame, show_notebook: bool = True) -> [lis
:return: if show_notebook is True, display in notebook; else return **plotly.graph_objs.Figure** list. :return: if show_notebook is True, display in notebook; else return **plotly.graph_objs.Figure** list.
""" """
_ic_df = _get_score_ic(pred_label) _ic_df = _get_score_ic(pred_label)
# FIXME: support HIGH-FREQ
_ic_df.index = _ic_df.index.strftime("%Y-%m-%d")
_figure = ScatterGraph( _figure = ScatterGraph(
_ic_df, _ic_df,
layout=dict(title="Score IC", xaxis=dict(type="category", tickangle=45)), layout=dict(
title="Score IC",
xaxis=dict(tickangle=45, rangebreaks=kwargs.get("rangebreaks", guess_plotly_rangebreaks(_ic_df.index))),
),
graph_kwargs={"mode": "lines+markers"}, graph_kwargs={"mode": "lines+markers"},
).figure ).figure
if show_notebook: if show_notebook:

View File

@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation. # Copyright (c) Microsoft Corporation.
# Licensed under the MIT License. # Licensed under the MIT License.
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import pandas as pd
def sub_fig_generator(sub_fs=(3, 3), col_n=10, row_n=1, wspace=None, hspace=None, sharex=False, sharey=False): def sub_fig_generator(sub_fs=(3, 3), col_n=10, row_n=1, wspace=None, hspace=None, sharex=False, sharey=False):
@@ -43,3 +44,31 @@ def sub_fig_generator(sub_fs=(3, 3), col_n=10, row_n=1, wspace=None, hspace=None
res = res.item() res = res.item()
yield res yield res
plt.show() plt.show()
def guess_plotly_rangebreaks(dt_index: pd.DatetimeIndex):
"""
This function `guesses` the rangebreaks required to remove gaps in datetime index.
It basically calculates the difference between a `continuous` datetime index and index given.
For more details on `rangebreaks` params in plotly, see
https://plotly.com/python/reference/layout/xaxis/#layout-xaxis-rangebreaks
Parameters
----------
dt_index: pd.DatetimeIndex
The datetimes of the data.
Returns
-------
the `rangebreaks` to be passed into plotly axis.
"""
dt_idx = dt_index.sort_values()
gaps = dt_idx[1:] - dt_idx[:-1]
min_gap = gaps.min()
gaps_to_break = {}
for gap, d in zip(gaps, dt_idx[:-1]):
if gap > min_gap:
gaps_to_break.setdefault(gap - min_gap, []).append(d + min_gap)
return [dict(values=v, dvalue=int(k.total_seconds() * 1000)) for k, v in gaps_to_break.items()]

View File

@@ -44,7 +44,7 @@ if not _CYTHON_INSTALLED:
# What packages are required for this module to be executed? # What packages are required for this module to be executed?
# `estimator` may depend on other packages. In order to reduce dependencies, it is not written here. # `estimator` may depend on other packages. In order to reduce dependencies, it is not written here.
REQUIRED = [ REQUIRED = [
"numpy>=1.12.0", "numpy>=1.12.0, <1.24",
"pandas>=0.25.1", "pandas>=0.25.1",
"scipy>=1.0.0", "scipy>=1.0.0",
"requests>=2.18.0", "requests>=2.18.0",